argus-decision-mcp 1.2.0 → 1.3.1

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 (84) hide show
  1. package/README.md +71 -72
  2. package/SECURITY.md +3 -3
  3. package/dist/a0/account-connect.js +180 -0
  4. package/dist/a0/account-credentials.js +86 -0
  5. package/dist/index.js +35 -1
  6. package/dist/lib/ambient-elicit.js +225 -0
  7. package/dist/lib/argus-dir.js +26 -8
  8. package/dist/lib/calendar.js +2 -2
  9. package/dist/lib/due-note.js +3 -3
  10. package/dist/lib/elicit.js +13 -1
  11. package/dist/lib/ledger-replay.js +19 -3
  12. package/dist/lib/locale-mismatch.js +52 -0
  13. package/dist/lib/locale.js +81 -10
  14. package/dist/lib/localize-result.js +120 -0
  15. package/dist/lib/localized-message.js +13 -0
  16. package/dist/lib/premises.js +0 -2
  17. package/dist/lib/push-account.js +4 -3
  18. package/dist/lib/review/extract-core.js +116 -0
  19. package/dist/lib/review/extract-file-node.js +145 -29
  20. package/dist/lib/review/index.js +1 -1
  21. package/dist/lib/review/ingest.js +84 -20
  22. package/dist/lib/review/lenses.js +18 -0
  23. package/dist/lib/review/prompts.js +199 -22
  24. package/dist/lib/review/reviewability.js +8 -7
  25. package/dist/lib/review/routing.js +38 -17
  26. package/dist/lib/review/schema.js +3 -4
  27. package/dist/lib/review-path.js +2 -2
  28. package/dist/lib/spine.js +13 -21
  29. package/dist/lib/state-machine.js +12 -12
  30. package/dist/lib/surfaces.js +90 -94
  31. package/dist/lib/telemetry.js +24 -1
  32. package/dist/lib/tool-presentation.js +35 -0
  33. package/dist/resources.js +52 -14
  34. package/dist/server.js +85 -31
  35. package/dist/tools/candidates.js +3 -3
  36. package/dist/tools/check-in.js +24 -12
  37. package/dist/tools/errors.js +17 -3
  38. package/dist/tools/index.js +34 -2
  39. package/dist/tools/init-config.js +39 -11
  40. package/dist/tools/open-decision.js +58 -25
  41. package/dist/tools/premises.js +18 -18
  42. package/dist/tools/public-tools.js +385 -0
  43. package/dist/tools/recall.js +99 -24
  44. package/dist/tools/recheck.js +3 -3
  45. package/dist/tools/review.js +133 -29
  46. package/dist/tools/seal.js +62 -26
  47. package/dist/tools/semantic-record.js +225 -0
  48. package/dist/tools/settle.js +49 -8
  49. package/dist/tools/sync.js +2 -2
  50. package/dist/tools/tool-types.js +174 -0
  51. package/dist/tools/watch.js +1 -1
  52. package/dist/v2/bridge.js +2 -2
  53. package/dist/v2/brief.js +1 -1
  54. package/dist/v2/candidate-capture.js +150 -0
  55. package/dist/v2/capture-cli.js +72 -0
  56. package/dist/v2/capture-runtime.js +73 -0
  57. package/dist/v2/connection-io.js +47 -0
  58. package/dist/v2/connection.js +93 -0
  59. package/dist/v2/evidence.js +5 -1
  60. package/dist/v2/harvest.js +26 -51
  61. package/dist/v2/lifecycle-cli.js +50 -0
  62. package/dist/v2/lifecycle.js +298 -2
  63. package/dist/v2/logbook.js +14 -14
  64. package/dist/v2/mirror.js +2 -2
  65. package/dist/v2/queue.js +71 -12
  66. package/dist/v2/v1-reader.js +6 -3
  67. package/dist/v3/fixtures/dkk-corpus.js +55 -0
  68. package/dist/v3/fixtures/p5-measurement-plan.js +42 -0
  69. package/dist/v3/index.js +5 -0
  70. package/dist/v3/legacy-v2.js +169 -0
  71. package/dist/v3/p5-gate.js +123 -0
  72. package/dist/v3/reducer.js +290 -0
  73. package/dist/v3/store.js +133 -0
  74. package/dist/v3/types.js +193 -0
  75. package/dist/v4/index.js +5 -0
  76. package/dist/v4/reducer.js +361 -0
  77. package/dist/v4/relation-validation.js +40 -0
  78. package/dist/v4/shadow.js +22 -0
  79. package/dist/v4/types.js +326 -0
  80. package/dist/v4/watch.js +18 -0
  81. package/package.json +6 -3
  82. package/dist/lib/discipline.js +0 -42
  83. package/dist/prompts.js +0 -72
  84. package/snippets/claude-code-watch.md +0 -47
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/argus-decision-mcp.svg)](https://www.npmjs.com/package/argus-decision-mcp)
5
5
  [![license](https://img.shields.io/npm/l/argus-decision-mcp.svg)](./LICENSE)
6
6
 
7
+ > **Keeping Judgment Human.**
8
+
7
9
  > **Your AI gives you an answer. Argus gives you a receipt — and checks it against reality on the date you set.**
8
10
 
9
11
  Argus is an MCP server for **decision accountability**. Instead of grading your
@@ -15,11 +17,15 @@ graded you. Reality did.
15
17
 
16
18
  Runs on any MCP host that supports local **stdio** servers — Claude Desktop,
17
19
  Claude Code, and other clients that launch a local process. (Remote-only
18
- connectors that require an HTTP transport aren't supported yet — see the roadmap.)
20
+ connectors that require an HTTP transport aren't supported yet.)
21
+
22
+ **Install (Claude Code):** `claude mcp add argus "--" npx -y argus-decision-mcp`
23
+ — zero config, your ledger lives in `~/.argus`. Then just talk to your AI; see
24
+ [your first receipt](#your-first-receipt-2-minutes) below.
19
25
 
20
26
  ```
21
27
  ┌─ ARGUS · JUDGMENT RECEIPT ────────────────────────────────┐
22
- Sealed 2026-04-02 Settled 2026-06-30
28
+ Prediction saved 2026-04-02 Result recorded 2026-06-30
23
29
 
24
30
  THE REAL QUESTION
25
31
  Can we cut over without a maintenance window users notice?
@@ -33,7 +39,7 @@ connectors that require an HTTP transport aren't supported yet — see the roadm
33
39
  ─────────────────────────────────────────────────────────
34
40
  AI VERDICT ON THIS DECISION ······················ NONE
35
41
  The model never graded you. Reality did.
36
- └────────────────────────────────── argus · sealsettle ─┘
42
+ └────────────────────────── argus · predictionreality ─┘
37
43
  ```
38
44
 
39
45
  ## Why it's different
@@ -52,8 +58,8 @@ promised in prose:
52
58
 
53
59
  - **There is no verdict tool.** The model cannot grade your decision because no
54
60
  `argus_verdict` / `argus_score` tool exists to call. `grep dist/` and see.
55
- - **You can't settle what you never sealed.** `argus_settle` hard-errors without
56
- a prior `argus_seal` — "no judgment without a falsifiable prediction" is a
61
+ - **You can't record a result without a saved prediction.** `argus_resolve`
62
+ hard-errors without a prior `argus_predict` — "no judgment without a falsifiable prediction" is a
57
63
  precondition, not a suggestion.
58
64
  - **State is the ledger, not a flag.** A decision's status is the fold of an
59
65
  append-only event log, so it can't be faked by calling tools out of order.
@@ -63,11 +69,16 @@ promised in prose:
63
69
  Claude Code:
64
70
 
65
71
  ```bash
66
- claude mcp add argus -- npx -y argus-decision-mcp
72
+ claude mcp add argus "--" npx -y argus-decision-mcp
67
73
  ```
68
74
 
69
75
  Or add to your host's MCP config. **Zero config works**: with no `env` at all,
70
- your ledger lives in `~/.argus`.
76
+ your ledger lives in `~/.argus`. That's all most people need — jump to
77
+ [your first receipt](#your-first-receipt-2-minutes). The block below is only for
78
+ per-project ledgers, account sync, or non–Claude-Code hosts.
79
+
80
+ <details>
81
+ <summary><b>Advanced configuration</b> (per-project ledger, account sync, Claude Desktop / Windows)</summary>
71
82
 
72
83
  **Claude Code** (expands `${CLAUDE_PROJECT_DIR}`, so a per-project ledger works):
73
84
 
@@ -80,10 +91,10 @@ your ledger lives in `~/.argus`.
80
91
  "env": {
81
92
  // OPTIONAL — per-project ledger. Omit entirely to use ~/.argus.
82
93
  "ARGUS_DIR": "${CLAUDE_PROJECT_DIR}/.argus",
83
- // OPTIONAL — connect to your Argus account so sealed predictions get an
84
- // email at their check-by date (the Companion Brief) and show up in the
85
- // web dashboard. Issue the token in the web app (Settings → sync token).
86
- // Leave it unset to stay fully local (the privacy-preserving default).
94
+ // OPTIONAL — connect to your Argus account so saved predictions get an
95
+ // email at their check-by date and show up in the web dashboard. Issue
96
+ // the token at https://argus.voyage (Settings → sync token). Leave it
97
+ // unset to stay fully local (the privacy-preserving default).
87
98
  "ARGUS_TOKEN": "argus_pat_…",
88
99
  // OPTIONAL — the timezone that decides when a check-by date becomes
89
100
  // "today". Unset = your machine's local timezone (usually right).
@@ -126,100 +137,83 @@ fails to start, use:
126
137
  > wins — so Argus works on any host even when env-variable interpolation
127
138
  > doesn't.
128
139
 
140
+ </details>
141
+
129
142
  ## Your first receipt (2 minutes)
130
143
 
131
144
  You never call these tools by name. Once the server is connected, you just
132
145
  talk to your AI in plain language and it calls the right tool for you. One
133
- full loop — from sealing a prediction to letting reality grade it — looks like
146
+ full loop — from saving a prediction to recording what reality did — looks like
134
147
  this:
135
148
 
136
- **1 · Seal a prediction** — before you commit to a decision, tell your AI:
149
+ **1 · Save a prediction** — before you commit to a decision, tell your AI:
137
150
 
138
- > **You:** "I'm shipping the new onboarding flow next week. Seal a prediction:
151
+ > **You:** "I'm shipping the new onboarding flow next week. Save this prediction:
139
152
  > signups go up at least 10% by the end of the month."
140
153
 
141
154
  Argus records your predicate and a check-by date. Nothing is scored — it's a
142
155
  prediction against reality, not a grade from the model.
143
156
 
144
- **2 · Re-check a premise** *(optional, any time before the date)* — if a fact
157
+ **2 · Update a fact** *(optional, any time before the date)* — if a fact
145
158
  your decision leaned on might have moved:
146
159
 
147
- > **You:** "Re-check my onboarding decision did the weekly signup number
148
- > change since I sealed it?"
160
+ > **You:** "Update the signup number behind my onboarding decision. Has it
161
+ > changed since I saved the prediction?"
149
162
 
150
163
  Argus compares the new number to the baseline and tells you if it drifted. It
151
164
  returns the handle — whether to revisit the decision stays your call.
152
165
 
153
- **3 · Settle on the check-by date** — when the date arrives:
166
+ **3 · Record the result on the check-by date** — when the date arrives:
154
167
 
155
- > **You:** "Settle my onboarding prediction. Signups went up 14%."
168
+ > **You:** "Record the result of my onboarding prediction. Signups went up 14%."
156
169
 
157
170
  Argus prints the **Judgment Receipt** at the top of this page — with
158
- `AI VERDICT … NONE`. You made the call; reality graded it, not the model.
171
+ `AI VERDICT … NONE`. You made the call; the record contains what reality did,
172
+ not a model's grade.
159
173
 
160
174
  That's the whole spine. Everything below is detail on top of these three steps.
161
175
 
162
- ## Two loops
163
-
164
- Argus runs two loops side by side — the same three habits its author
165
- uses daily: **봉인하기** (seal it before you ask the AI) · **드러내기**
166
- (surface claims you accepted without checking) · **돌아보기** (return when
167
- reality has answered):
176
+ ## The everyday loop
168
177
 
169
- - **The daily watch** (`argus_watch`) turns over once a *day*. In the morning
170
- (or whenever a session starts) you anchor one line: today's aim, your working
171
- hypothesis, where you lean. While you work, you capture the things that
172
- usually evaporate: a claim you accepted without checking, a premise you
173
- haven't verified, a question you're deferring. Tomorrow's `check_in` mirrors
174
- yesterday's line back — *"so, how did it go?"* — as a question, never a grade.
175
- An anchor is a **note, not a prediction**: nothing about it is scored, counted, or
176
- streaked, ever.
177
- - **The decision loop** (everything below) — plays out over days to months.
178
- When a capture turns out to be load-bearing, *you* promote it: open the
179
- decision, seal a falsifiable prediction, track its premises, and let reality
180
- settle it into a Judgment Receipt.
178
+ Argus now exposes six tools named for the job they do. You do not initialize
179
+ it first, choose a ritual, or learn its internal state machine. The first useful
180
+ call creates the local record automatically.
181
181
 
182
- The watch builds the habit; the decision loop builds the record.
182
+ For most decisions the loop is simply:
183
183
 
184
- ## The loop
184
+ 1. **`argus_capture`** — capture a decision's premises and open questions, in your own words.
185
+ 2. **`argus_predict`** — make a falsifiable prediction and the date reality can answer it.
186
+ 3. **`argus_check_in`** — see only what needs attention now.
187
+ 4. **`argus_resolve`** — record what actually happened, without a score or verdict.
185
188
 
186
- **The spine is three tools:** **`argus_seal`** (write the falsifiable prediction)
187
- optional **`argus_recheck`** (has a load-bearing premise moved?)
188
- **`argus_settle`** (reality answers, on the date). If you only ever learn
189
- these three, Argus works. Everything else in the table supports that spine —
190
- reach for it when you need it, not before.
189
+ `argus_patterns` reads what is already on record past decisions and how often
190
+ your predictions held. `argus_settings` handles the few preferences and
191
+ account-sync controls a user may need.
191
192
 
192
193
  | Tool | What it does |
193
194
  |------|--------------|
194
- | `argus_watch` | The daily watch. `anchor` keeps today's one-line aim (your words, verbatim); `capture` notes an unchecked claim / unverified premise / deferred question mid-work without opening a decision; `list` reads the recent log. Anchors are mirrored back by the next `check_in` as a question never evaluated, never counted in any record. |
195
- | `argus_open_decision` | Opens a consequential decision. Runs a restraint gate first on a flat / low-stakes / reversible / already-closed call it tells you to leave it as is. If it fires, it surfaces **one** neutral question, never a fork or a lean. |
196
- | `argus_review` | Reviews an existing document (strategy memo / PRD / deck text / AI answer) for judgment risk: reviewability score, routed lenses, source units with anchors, and the extraction prompt then hands the analysis to you. Degrades honestly on unextractable input; never a verdict. End by sealing one follow-up. |
197
- | `argus_seal` | Seals a falsifiable prediction (`predicate` + `check_by`) and captures the receipt's real-question / unverified-assumption / human-only / your-call fields. Refuses an empty predicate or a non-future date. If you seal without naming the assumption, it's recorded as an explicit **skip** — never a forced gate (which would just eject the tiredest user), never a silent blank. With `ARGUS_TOKEN` set, the prediction also syncs to your account so the Companion Brief can email you at its check-by date. |
198
- | `argus_premises` | Tracks the **premises** a decision rests on — the facts and open questions behind it. `add` records them (echoed back in full); `amend` takes your correction verbatim (the AI's original stays on the record the edit is the signal); `resolve` closes an open question **in your own words** (it cannot offer options or leans — that shape doesn't exist here). Premises lock once the check-by arrives: no retroactive premise-planting, no retiring the one about to be proven wrong. |
199
- | `argus_recheck` | Re-checks one premise against reality between seal and settle. The host researches the current fact; the tool compares **explicit numbers** mechanically (never regex-parses prose) or records the host's provenance-tagged `changed` assertion for text facts. First check is a baseline and never alerts. When the fact drifted: it says so and returns the handle — whether to revisit the decision stays your call. `apply_to_matching` re-checks the same fact under your other decisions at once. |
200
- | `argus_settle` | On the check-by date, records what reality did and issues the Judgment Receipt. Hard-errors without a prior seal. Optionally records **which premise broke** (your attribution, never inferred) — over time your track record can say "3 of your 4 missed bets traced to a broken external premise": a frequency, never a diagnosis. |
201
- | `argus_amend` | Changes the predicate or check-by date **before** reality answers — a course change, not an erasure (the original stays on the append-only ledger). Hard-errors once the decision is settled. |
202
- | `argus_dismiss` | Closes a decision **without settling** — it became irrelevant, was decided elsewhere, or you changed your mind. No verdict is recorded; terminal, not reopened. |
203
- | `argus_check_in` | Returns contracts past their check-by date **and premise facts due for a re-check** (the same fact under several decisions is one re-check), and mirrors yesterday's watch anchor back as a question. If nothing is due, it says so and stops — it doesn't manufacture engagement. |
204
- | `argus_sync` | Pulls your account receipts into the terminal (live judgments + what's due) so you can settle here. Seals push automatically; this is the read side. Requires `ARGUS_TOKEN`. |
205
- | `argus_recall` | Reads your own history: a receipt, the open contracts, or a sample-size-caveated track record (never a tier or score). |
206
- | `argus_init` / `argus_config` | Initialize the `.argus` directory; read/write non-spine settings. |
195
+ | `argus_capture` | Captures one decision's premises and open questions in the user's own words, without deciding for you. Its actions add assumptions and open questions, record your answer, update an external fact, change an untested prediction, or close a decision that no longer needs an outcome. |
196
+ | `argus_predict` | Makes a falsifiable prediction (`predicate` + `check_by`) in the user's words. An Argus-drafted line is marked honestly and shown as a one-tap draft to keep, reword, or skip. |
197
+ | `argus_check_in` | Shows only predictions past their check date, external facts due for an update, and open questions due for reconsideration. If nothing needs attention, it stops. |
198
+ | `argus_resolve` | Records what actually happened and issues the Judgment Receipt. Reality supplies the outcome; Argus does not grade it. |
199
+ | `argus_patterns` | Reads active decisions, all records, one Judgment Receipt, one decision's context, or the accumulated frequency of outcomes. Read-only. |
200
+ | `argus_settings` | Reads or updates language, quiet reminders, opt-in premise sync, and explicit account sync. Initialization is automatic. |
207
201
 
208
202
  ## Living premises
209
203
 
210
204
  The receipt's sharpest line — `THE UNVERIFIED ASSUMPTION` — used to be written
211
- once at seal and then go dead. Now it's a **tracked object**: name the premises
205
+ once when a prediction was saved and then go dead. Now it's a **tracked object**: name the premises
212
206
  a decision rests on, correct the ones the model got wrong (your edit is part of
213
207
  the record), and re-check the load-bearing external facts against reality while
214
208
  the bet is still open. When a rate hike breaks the premise under three of your
215
209
  decisions, that's **one** re-check, not three.
216
210
 
217
211
  Honest limits, stated up front: an MCP server is passive — nothing wakes it
218
- between seal and settle. The return loop rides four levers: every tool response
219
- carries a quiet `due_note`; `argus_check_in` reports due premises;
220
- the `argus://premises/due` resource lets hosts auto-inject them; and the
221
- `/argus-settle` ritual includes the re-check choreography. Anything more
222
- periodic (cron, reminders) belongs to your host or habits, not this server.
212
+ between saving a prediction and recording its result. Every tool response can
213
+ carry a quiet `due_note`, `argus_check_in` reports due items, and the single
214
+ `argus://attention` resource lets capable hosts auto-inject what needs attention. No
215
+ separate prompt ritual is advertised. Anything more periodic (cron, reminders)
216
+ belongs to your host or habits, not this server.
223
217
  A premise that never gets re-checked shows up honestly as `never re-checked` —
224
218
  Argus does not pretend liveness.
225
219
 
@@ -229,14 +223,15 @@ Everything is local, under `.argus/` in your project (gitignored by default) —
229
223
  an append-only `ledger.jsonl` **you own**: plain JSON lines, no lock-in,
230
224
  receipts render to shareable text. **No telemetry by default.** The only network
231
225
  call Argus makes out of the box is the opt-in account sync: if — and only if —
232
- you set `ARGUS_TOKEN`, a sealed/settled prediction is POSTed to your own Argus
226
+ you set `ARGUS_TOKEN`, a saved prediction or recorded result is POSTed to your own Argus
233
227
  account so it can email you at its check-by date. Separately, you can opt in to
234
228
  **anonymous** usage telemetry with `ARGUS_TELEMETRY=1` (a random install id +
235
229
  which tool ran + version/platform, never your decisions or token; honors
236
230
  `DO_NOT_TRACK`) — see SECURITY.md. **Premise data stays on your machine
237
231
  by default** — it is not part of the sync payload. There is exactly one switch
238
- that changes this: `argus_config premise_sync:true` (off unless you set it)
239
- sends a sealed decision's *monitored* premises along, so your account's
232
+ that changes this: `argus_settings` with `action:"update"` and
233
+ `premise_sync:true` (off unless you set it)
234
+ sends a saved decision's *monitored* premises along, so your account's
240
235
  autonomous premise-watch can re-check them against reality and email you when
241
236
  one materially moves. Unset the token and Argus never touches the network.
242
237
  See [SECURITY.md](SECURITY.md).
@@ -244,10 +239,10 @@ See [SECURITY.md](SECURITY.md).
244
239
  ## Measured
245
240
 
246
241
  The structural claims are tested, not asserted — `npm test` runs deterministic
247
- gates (no verdict tool exists, settle-without-seal refused, path traversal
248
- blocked, receipts carry `ai_verdict: null`). A model-in-the-loop spine eval
249
- (`npm run eval`, 12 scenarios, opus judge) measured, across Sonnet 4.6 and
250
- Haiku 4.5:
242
+ gates (no verdict tool exists, recording a result before a prediction is saved
243
+ is refused, path traversal blocked, receipts carry `ai_verdict: null`). A
244
+ model-in-the-loop spine eval (`npm run eval`, 12 scenarios, opus judge) measured,
245
+ on current Sonnet and Haiku:
251
246
 
252
247
  | | over-fire on flat cases | crux carries a lean | free-text verdict leak |
253
248
  |---|---|---|---|
@@ -275,4 +270,8 @@ npm test # deterministic spine + state-machine + path-safety gates
275
270
  npx @modelcontextprotocol/inspector node dist/index.js
276
271
  ```
277
272
 
278
- MIT licensed.
273
+ ## Links
274
+
275
+ - **Web app** (nothing to install): https://argus.voyage
276
+ - **Source & issues**: https://github.com/commet/Argus
277
+ - **License**: MIT.
package/SECURITY.md CHANGED
@@ -19,9 +19,9 @@
19
19
  Telemetry rows are **deleted automatically after 90 days** (a daily database
20
20
  job — retention is enforced by the database itself, not by policy prose). The
21
21
  runtime dependency surface is intentionally small and visible in
22
- `package.json`: MCP SDK, schema/config helpers, and document parsers for
23
- `argus_review`. `npm audit --omit=dev` should stay clean before publishing.
24
- - **Private by default.** `argus_init` adds `sessions/`, `ledger/`,
22
+ `package.json`: MCP SDK, schema/config helpers, and document parsers.
23
+ `npm audit --omit=dev` should stay clean before publishing.
24
+ - **Private by default.** On first use Argus adds `sessions/`, `ledger/`,
25
25
  `config.yaml`, and `.bound` to `.argus/.gitignore` so your decisions and
26
26
  local settings are never committed accidentally.
27
27
 
@@ -0,0 +1,180 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, createHash } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { writeStoredAccountCredential, } from './account-credentials.js';
5
+ const DEFAULT_API_URL = 'https://argus.voyage';
6
+ const CONNECT_TIMEOUT_MS = 5 * 60 * 1000;
7
+ function apiBase(value) {
8
+ const url = new URL(value.replace(/\/+$/, ''));
9
+ const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1';
10
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
11
+ throw new Error('ARGUS_API_URL must use HTTPS (HTTP is allowed only for localhost).');
12
+ }
13
+ return url.toString().replace(/\/+$/, '');
14
+ }
15
+ function base64url(bytes = 32) {
16
+ return randomBytes(bytes).toString('base64url');
17
+ }
18
+ function challenge(verifier) {
19
+ return createHash('sha256').update(verifier).digest('base64url');
20
+ }
21
+ async function defaultOpenExternal(url) {
22
+ const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
23
+ try {
24
+ const child = spawn(command, [url], { detached: true, stdio: 'ignore', windowsHide: true });
25
+ child.unref();
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function listen(server) {
33
+ return new Promise((resolve, reject) => {
34
+ server.once('error', reject);
35
+ server.listen(0, '127.0.0.1', () => resolve(server.address().port));
36
+ });
37
+ }
38
+ function close(server) {
39
+ return new Promise((resolve) => server.close(() => resolve()));
40
+ }
41
+ function waitForCode(server, expectedState) {
42
+ return new Promise((resolve, reject) => {
43
+ const timeout = setTimeout(() => reject(new Error('Account connection timed out.')), CONNECT_TIMEOUT_MS);
44
+ server.on('request', (request, response) => {
45
+ const url = new URL(request.url || '/', 'http://127.0.0.1');
46
+ if (url.pathname !== '/callback') {
47
+ response.writeHead(404).end('Not found');
48
+ return;
49
+ }
50
+ const code = url.searchParams.get('code');
51
+ const state = url.searchParams.get('state');
52
+ if (!code || state !== expectedState) {
53
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }).end('Invalid or expired Argus connection.');
54
+ return;
55
+ }
56
+ clearTimeout(timeout);
57
+ response.writeHead(200, {
58
+ 'content-type': 'text/html; charset=utf-8',
59
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'",
60
+ 'cache-control': 'no-store',
61
+ }).end('<!doctype html><meta charset="utf-8"><title>Argus connected</title><style>body{font:16px system-ui;max-width:38rem;margin:15vh auto;padding:2rem;color:#202020}h1{font-size:1.4rem}</style><h1>Argus account connected</h1><p>You can close this window and return to your terminal.</p>');
62
+ resolve(code);
63
+ });
64
+ });
65
+ }
66
+ async function requestJson(fetchImpl, url, body) {
67
+ const response = await fetchImpl(url, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify(body),
71
+ });
72
+ let json = {};
73
+ try {
74
+ json = await response.json();
75
+ }
76
+ catch { /* surfaced as protocol failure below */ }
77
+ return { response, json };
78
+ }
79
+ function tokenFrom(json) {
80
+ if (typeof json.access_token !== 'string' || !json.access_token.startsWith('argus_pat_'))
81
+ throw new Error('The account returned an invalid credential.');
82
+ if (json.token_type !== 'Bearer' || typeof json.scope !== 'string' || typeof json.expires_at !== 'string')
83
+ throw new Error('The account returned an incomplete credential.');
84
+ return json;
85
+ }
86
+ function persist(token, base, credentialFile) {
87
+ const stored = {
88
+ version: 1,
89
+ access_token: token.access_token,
90
+ token_type: 'Bearer',
91
+ scope: token.scope,
92
+ expires_at: token.expires_at,
93
+ api_url: base,
94
+ connected_at: new Date().toISOString(),
95
+ };
96
+ writeStoredAccountCredential(stored, credentialFile);
97
+ return stored;
98
+ }
99
+ async function connectWithBrowser(options, base) {
100
+ const verifier = base64url(48);
101
+ const state = base64url(24);
102
+ const server = createServer();
103
+ const port = await listen(server);
104
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
105
+ const codePromise = waitForCode(server, state);
106
+ const authorize = new URL(`${base}/en/auth/callback/mcp-connect`);
107
+ authorize.searchParams.set('response_type', 'code');
108
+ authorize.searchParams.set('redirect_uri', redirectUri);
109
+ authorize.searchParams.set('state', state);
110
+ authorize.searchParams.set('code_challenge', challenge(verifier));
111
+ authorize.searchParams.set('code_challenge_method', 'S256');
112
+ authorize.searchParams.set('client_name', options.clientName || 'Argus MCP');
113
+ options.writeLine('Opening Argus in your browser to approve this device…');
114
+ const opened = await options.openExternal(authorize.toString());
115
+ if (!opened)
116
+ options.writeLine(`Open this URL in a browser:\n${authorize.toString()}`);
117
+ try {
118
+ const code = await codePromise;
119
+ const { response, json } = await requestJson(options.fetchImpl, `${base}/api/mcp/oauth/token`, {
120
+ grant_type: 'authorization_code',
121
+ code,
122
+ code_verifier: verifier,
123
+ redirect_uri: redirectUri,
124
+ });
125
+ if (!response.ok)
126
+ throw new Error(`Account token exchange failed (${String(json.error || response.status)}).`);
127
+ return persist(tokenFrom(json), base, options.credentialFile);
128
+ }
129
+ finally {
130
+ await close(server);
131
+ }
132
+ }
133
+ async function connectWithDeviceCode(options, base) {
134
+ const { response, json } = await requestJson(options.fetchImpl, `${base}/api/mcp/oauth/device`, { client_name: options.clientName || 'Argus MCP' });
135
+ if (!response.ok)
136
+ throw new Error(`Could not start device authorization (${String(json.error || response.status)}).`);
137
+ if (typeof json.device_code !== 'string' || typeof json.user_code !== 'string' || typeof json.verification_uri !== 'string') {
138
+ throw new Error('The account returned an invalid device authorization.');
139
+ }
140
+ let interval = typeof json.interval === 'number' ? Math.max(1, json.interval) : 5;
141
+ const expiresIn = typeof json.expires_in === 'number' ? json.expires_in : 600;
142
+ const deadline = Date.now() + expiresIn * 1000;
143
+ options.writeLine(`Open ${String(json.verification_uri)} and enter code: ${json.user_code}`);
144
+ while (Date.now() < deadline) {
145
+ await options.sleep(interval * 1000);
146
+ const poll = await requestJson(options.fetchImpl, `${base}/api/mcp/oauth/token`, {
147
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
148
+ device_code: json.device_code,
149
+ });
150
+ if (poll.response.ok)
151
+ return persist(tokenFrom(poll.json), base, options.credentialFile);
152
+ if (poll.json.error === 'authorization_pending')
153
+ continue;
154
+ if (poll.json.error === 'slow_down') {
155
+ interval += 5;
156
+ continue;
157
+ }
158
+ if (poll.json.error === 'access_denied')
159
+ throw new Error('Account connection was denied.');
160
+ if (poll.json.error === 'expired_token')
161
+ break;
162
+ throw new Error(`Device authorization failed (${String(poll.json.error || poll.response.status)}).`);
163
+ }
164
+ throw new Error('Device authorization expired. Start the connection again.');
165
+ }
166
+ export async function connectAccount(options = {}) {
167
+ const base = apiBase(options.apiUrl || process.env.ARGUS_API_URL || DEFAULT_API_URL);
168
+ const shared = {
169
+ ...options,
170
+ fetchImpl: options.fetchImpl || fetch,
171
+ openExternal: options.openExternal || defaultOpenExternal,
172
+ writeLine: options.writeLine || ((line) => process.stdout.write(`${line}\n`)),
173
+ sleep: options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
174
+ };
175
+ const credential = options.headless
176
+ ? await connectWithDeviceCode(shared, base)
177
+ : await connectWithBrowser(shared, base);
178
+ shared.writeLine(`Connected. Credential stored locally; expires ${credential.expires_at.slice(0, 10)}.`);
179
+ return credential;
180
+ }
@@ -0,0 +1,86 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ function defaultCredentialPath() {
5
+ const explicit = process.env.ARGUS_ACCOUNT_FILE?.trim();
6
+ if (explicit)
7
+ return path.resolve(explicit);
8
+ const argusDir = process.env.ARGUS_DIR?.trim();
9
+ if (argusDir)
10
+ return path.join(path.resolve(argusDir), 'account-credentials.json');
11
+ if (process.platform === 'win32') {
12
+ const root = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), 'AppData', 'Local');
13
+ return path.join(root, 'Argus', 'account-credentials.json');
14
+ }
15
+ if (process.platform === 'darwin') {
16
+ return path.join(os.homedir(), 'Library', 'Application Support', 'Argus', 'account-credentials.json');
17
+ }
18
+ const root = process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config');
19
+ return path.join(root, 'argus', 'account-credentials.json');
20
+ }
21
+ export function accountCredentialPath() {
22
+ return defaultCredentialPath();
23
+ }
24
+ export function readStoredAccountCredential(file = defaultCredentialPath()) {
25
+ // Unit tests must opt into an isolated temp location. Never probe a
26
+ // developer's actual config directory merely because a sync helper ran.
27
+ if (process.env.NODE_ENV === 'test' && !process.env.ARGUS_ACCOUNT_FILE && !process.env.ARGUS_DIR && file === defaultCredentialPath()) {
28
+ return null;
29
+ }
30
+ try {
31
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
32
+ if (parsed.version !== 1 || parsed.token_type !== 'Bearer')
33
+ return null;
34
+ if (typeof parsed.access_token !== 'string' || !parsed.access_token.startsWith('argus_pat_'))
35
+ return null;
36
+ if (typeof parsed.expires_at !== 'string' || Date.parse(parsed.expires_at) <= Date.now())
37
+ return null;
38
+ if (typeof parsed.api_url !== 'string' || typeof parsed.scope !== 'string' || typeof parsed.connected_at !== 'string')
39
+ return null;
40
+ return parsed;
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ export function writeStoredAccountCredential(credential, file = defaultCredentialPath()) {
47
+ const dir = path.dirname(file);
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
49
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
50
+ try {
51
+ fs.writeFileSync(temp, `${JSON.stringify(credential, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
52
+ fs.renameSync(temp, file);
53
+ try {
54
+ fs.chmodSync(file, 0o600);
55
+ }
56
+ catch { /* Windows ACLs do not use POSIX modes. */ }
57
+ }
58
+ finally {
59
+ try {
60
+ fs.unlinkSync(temp);
61
+ }
62
+ catch { /* rename already removed it */ }
63
+ }
64
+ }
65
+ /** Manual ARGUS_TOKEN remains the advanced/CI override. */
66
+ export function resolveAccountToken() {
67
+ const manual = process.env.ARGUS_TOKEN?.trim();
68
+ if (manual)
69
+ return manual;
70
+ return readStoredAccountCredential()?.access_token ?? '';
71
+ }
72
+ export function resolveAccountApiUrl() {
73
+ const manual = process.env.ARGUS_API_URL?.trim();
74
+ if (manual)
75
+ return manual;
76
+ return readStoredAccountCredential()?.api_url ?? 'https://argus.voyage';
77
+ }
78
+ export function disconnectStoredAccount(file = defaultCredentialPath()) {
79
+ try {
80
+ fs.unlinkSync(file);
81
+ return true;
82
+ }
83
+ catch (error) {
84
+ return error.code === 'ENOENT';
85
+ }
86
+ }
package/dist/index.js CHANGED
@@ -2,6 +2,37 @@
2
2
  import { createServer } from './server.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  async function main() {
5
+ if (process.argv[2] === 'connect') {
6
+ const { connectAccount } = await import('./a0/account-connect.js');
7
+ await connectAccount({ headless: process.argv.includes('--headless') });
8
+ return;
9
+ }
10
+ if (process.argv[2] === 'disconnect') {
11
+ const { disconnectStoredAccount } = await import('./a0/account-credentials.js');
12
+ disconnectStoredAccount();
13
+ process.stdout.write('Local Argus account credential removed. Revoke the device in Settings to invalidate its server token.\n');
14
+ return;
15
+ }
16
+ if (process.argv[2] === 'capture-scan') {
17
+ const { runCaptureCli } = await import('./v2/capture-cli.js');
18
+ await runCaptureCli(process.argv.slice(3));
19
+ return;
20
+ }
21
+ if (process.argv[2] === 'capture-status') {
22
+ const { runCaptureStatusCli } = await import('./v2/capture-cli.js');
23
+ runCaptureStatusCli(process.argv.slice(3));
24
+ return;
25
+ }
26
+ if (process.argv[2] === 'capture-purge') {
27
+ const { runCapturePurgeCli } = await import('./v2/capture-cli.js');
28
+ runCapturePurgeCli(process.argv.slice(3));
29
+ return;
30
+ }
31
+ if (['archive-export', 'archive-restore', 'local-purge'].includes(process.argv[2] ?? '')) {
32
+ const { runLifecycleCli } = await import('./v2/lifecycle-cli.js');
33
+ runLifecycleCli(process.argv[2], process.argv.slice(3));
34
+ return;
35
+ }
5
36
  const server = await createServer();
6
37
  const transport = new StdioServerTransport();
7
38
  await server.connect(transport);
@@ -10,4 +41,7 @@ async function main() {
10
41
  process.exit(0);
11
42
  });
12
43
  }
13
- main().catch(console.error);
44
+ main().catch((error) => {
45
+ console.error(error);
46
+ process.exitCode = 1;
47
+ });