@floh-solutions/pharos-cli
v0.29.0
Published
Azure DevOps from a headless shell, for an agent: the whole context of a task in one call, and the wiki/comment verbs Microsoft's MCP server does not ship.
Readme
pharos
Azure DevOps from a headless shell, built for an agent to drive.
npm i -g @floh-solutions/pharos-cli
export ADO_ORG=your-org ADO_PROJECT=YourProject ADO_PAT=…
pharos task 210Why it exists
Microsoft's @azure-devops/mcp server is missing capabilities that matter once
Claude is doing real work on a board. Enumerated against its shipped tool list,
not assumed:
| | | |---|---| | wiki comments — list, add, edit, delete, react | no tool at all | | work item comments — delete, react/unreact | absent (it has add and update only) | | wiki page delete | absent | | service hook subscriptions | absent |
And it authenticates with an interactive browser login, so an unattended agent blocks on a modal nobody is there to answer.
The command it exists for
pharos task 210One call returns the work item, its fields, its comments, its attachments, its relations with their titles, and the full content of every linked wiki page plus the discussion on those pages.
Assembled by hand that is five or six round trips across two different tools, and one of them is impossible. It is what an agent needs in order to start: the plan a colleague wrote on a wiki page, the argument underneath it, and the dependency that says this cannot begin yet. Anything forgotten is context the agent silently works without.
It gathers; it does not summarise or rank. Whatever could not be fetched lands
in problems[] rather than being quietly dropped — a context with an invisible
hole in it is worse than a short one, because it gets reasoned from
confidently.
Add --pretty for a human-readable brief.
The output is the interface
Success is JSON on stdout. Failure is JSON on stderr with a non-zero exit. An empty array and exit 0 is a query that matched nothing; that is a different fact from a 403, and prose makes it a guess.
| exit | meaning | what to do |
|---|---|---|
| 0 | it worked | carry on |
| 1 | the call failed | maybe retry — check kind, and retryAfterMs if rate-limited |
| 2 | called wrong, or not configured | never retry unchanged |
| 3 | refused by a guard here | re-run with --yes, or raise --max-writes |
Errors carry what you need to act:
{ "error": { "kind": "conflict", "expectedRev": 4, "actualRev": 5,
"hint": "Re-read the item and re-apply…" } }A bad token exits 2, not 1 — retrying it is pure noise. A conflict carries
both revisions, and says out loud that posting a comment bumps System.Rev, so
a rev mismatch is not proof that somebody edited the same field.
A failure that came from GitHub says so — "platform": "github" — because
pharos issue touches two platforms in one call and which one refused is the
first thing to know. Its absence means Azure DevOps or this tool, as before.
Guards
Every verb here is one an agent can call in a loop against a board real people depend on.
Destructive verbs refuse by default.
pharos wiki delete /Plans/Old # exit 3, nothing changed, preview attached
pharos wiki delete /Plans/Old --dry-run # exit 0 — the preview IS the request
pharos wiki delete /Plans/Old --yes # doneThe bare form exits non-zero on purpose. Exiting 0 with "applied": false
reads as success to anything that checks only the status, and an agent that
quietly does less looks identical to one that succeeded. --dry-run beats
--yes: the safe reading of a contradiction is the one that changes nothing.
Replacing a wiki page needs --yes; creating one does not. Creating adds,
replacing can destroy somebody's text. The read that decides which is also
where the If-Match version comes from, so the concurrency guard is not
something a caller can skip.
A write budget, counted at the transport. Default 20 per invocation, so a
runaway loop becomes a clean refusal rather than 500 work items. --max-writes 0
is read-only: reads work, every write is refused before it is sent.
It counts both platforms. pharos issue writes to Azure DevOps and to
GitHub in one invocation, and a cap that covered one of them because the other
left by a different transport would be a cap in name only.
It is not security — anything that can run this holds the token and can make the same REST calls directly. It shapes intent and catches accidents. A capability that must be impossible has to be withheld at the token.
Commands
task <id> the whole context, in one call
search <terms...> WHERE it is written — full text over work
items AND wiki pages, with the matching line
wiki list | tree | read | write | delete
comment list | add | edit | delete | react | unreact | reactors
comment hide | unhide | pin | unpin GitHub only
issue adopt | link | say | trail | drift | backfill | close
a GitHub issue and a work item, joined —
and kept honest
plan <file> an implementation plan becomes a work item treecomment takes any of three targets: a work item is its number (210), a wiki
page is an absolute path (/Plans/Foo), a GitHub issue is
owner/name#45. The leading slash is required by the wiki API itself — it
answers 404 without one — so the spelling that tells them apart is also the only
spelling that works, and the three grammars cannot collide.
Long text does not belong on a command line:
pharos comment add 210 --file review.md
pharos wiki write /Plans/Sprint-4 --stdin < plan.mdcomment on a GitHub issue — the surface gh half has and half has not
pharos comment list contoso/widgets#45 # ids, state, and what you MAY do
pharos comment edit contoso/widgets#45 900 --file fixed.md
pharos comment delete contoso/widgets#45 900 --yes
pharos comment react contoso/widgets#45 900 thumbs-up
pharos comment hide contoso/widgets#45 900 off-topic
pharos comment pin contoso/widgets#45 900This is the one place pharos reaches past the join, and it is two arguments
rather than one. Editing or deleting a comment can destroy the link record,
which nothing outside this repo knows to protect. And hide and pin have no
gh verb at all — they are GraphQL mutations with no REST route, so they pass
the same "nothing else can do this" test the issue verbs pass. Posting a new
comment is still gh issue comment or pharos issue say, and comment add on
a GitHub issue is refused with both of those named.
list is the load-bearing verb, and it is not one the macOS app needed: the
app has a window, an agent has this. Before it there was no way to learn a
GitHub comment id from this CLI at all, so nothing else here was addressable. It
prints both ids per comment — the REST number that edit, delete and reactions
address, and the node id that hide and pin take, because neither is derivable
from the other — plus a may object read from GitHub's own viewerCan… fields.
For a window those grey out a menu item; for an agent they turn a write that
would 403 into a fact known beforehand.
An edit cannot double or strip the pharos:v1 trailer. Send prose with no
trailer and the existing one is restored, with markerPreserved: true saying
so; send two, or one naming a different work item, or one on a comment that
never had one, and the edit is refused with the reason and the repair. The rule
lives in gh-core, not here, because it is a rule about the link record rather
than about this command — and the macOS app edits through the same package.
Deleting the comment that carries the trailer is allowed and never silent.
The --yes refusal names the work item about to lose its GitHub end and hands
over the pharos issue link that puts it back; --dry-run prints the same
preview and exits 0. Both read the comment first, because a preview that could
not say which of the two deletes this is would be a preview of the wrong thing.
Reactions are not one vocabulary across the two platforms. Azure DevOps
takes like dislike heart hooray smile confused; GitHub takes thumbs-up
thumbs-down laugh hooray confused heart rocket eyes. Passing one platform's
name to the other is refused rather than translated. The CLI spelling is words
because GitHub's own names for the first two are +1 and -1, and -1 as a
positional is read as a flag by every argument parser including this one.
issue — the GitHub edge, and only the edge
pharos issue adopt contoso/widgets#45 --parent 39
pharos issue link contoso/widgets#45 4821
pharos issue say contoso/widgets#45 --file reply.md
pharos issue trail 4821 # …or trail contoso/widgets#45
pharos issue drift # where the two platforms disagree
pharos issue backfill contoso/widgets --limit 25 --yes
pharos issue close 4821 --text "Shipped in 1.4.0."
pharos issue edit contoso/widgets#45 --title "Crash on resize" --yesAzure DevOps already has a GitHub integration and the link it makes carries nothing: title, body, comments, labels and state stay on their own island, and the transition only ever fires from a commit or a PR merge, never from closing an issue. That is the gap, and it is the whole pitch — not "there is no integration".
Eight verbs, because gh already exists. Listing, viewing and commenting on
an issue is gh's job and it is better at it — closing one end is gh issue
close, and closing the pair is pharos issue close; a second GitHub CLI
would be the drifting duplicate this repo has been bitten by three times.
Pharos owns what gh cannot see: the join.
edit is the one verb that overlaps gh head on, and it earns that by
refusing. gh issue edit writes the same fields and writes them well; what it
cannot do is notice that somebody changed the thing you are overwriting. It is
last-write-wins — if a colleague renamed the issue while you were composing,
their rename is gone and neither of you is told. Fine for a human at a terminal,
wrong for an agent that reads, thinks for a minute, and writes into a world that
moved. Want an unguarded edit? gh issue edit is right there.
The guard is a value compare, because GitHub offers nothing better:
PATCH /repos/{o}/{r}/issues/{n} answers 400 to If-Match (measured), and
updated_at and the ETag both move when somebody merely comments, so a
timestamp guard would refuse a good edit for a change that touched nothing. So
edit re-reads immediately before sending and refuses only if the text it is
about to overwrite moved. Pass --if-title / --if-body / --if-body-file to
guard against what you saw rather than against this command's own read; the
output says which, as fields.guard.window. It narrows the race to one round
trip and does not close it, and it says so.
Two things GitHub does quietly, which edit reports:
POST …/labels creates a repository label that does not exist (so an unknown
one is refused with near misses — --create-label is how you mean it), and
POST …/assignees ignores a login it will not assign while still answering
201 (so read assignees.ignored). Labels and assignees go through their own
add/remove endpoints, never the whole-issue PATCH, which carries them as whole
arrays and would overwrite every label on every write.
adopt creates the work item and writes both ends of the link:
| | where | |
|---|---|---|
| 1 | a Hyperlink relation on the work item | measured, not chosen — _apis/githubconnections answers 401 on Pharos's PAT scopes while a control call answers 200, so the connection id a native vstfs:///GitHub/Issue/… link needs cannot be read at all |
| 2 | one comment on the issue: AB#4821 and <!-- pharos:v1 repo=… issue=… ado=… --> | AB# is free official linking wherever the Azure Boards app happens to be installed and harmless text where it is not. The trailer is what every machine reads, including the loop guard |
The marker goes in a comment, never the issue body. Editing a reporter's body collides with them, is rude on a public repo, and needs write access we may not have on a community issue. A comment is append-only and needs none of that.
The relation and the parent ride in the same patch as the fields, so a work item is never briefly pointing at nothing. Azure DevOps is written first because the work item id does not exist until it lands — so the one failure this can leave is always the same shape, and it is reported rather than swallowed:
{ "error": { "createdWorkItem": 4821, "halfLinked": true, "wrote": ["hyperlink"],
"recover": "pharos issue link contoso/widgets#45 4821" } }link writes only the half that is missing, which makes it both the way to join
an existing pair and the repair for exactly that state.
Adopting an issue twice never makes a second work item, and there are three different answers behind that one sentence. Which one you get says what state the pair was in:
| the issue carries a marker | exit 0, created: false — the work item it names, and nothing written. The state you asked for already holds |
| a work item links the issue and the issue says nothing | exit 3, halfLinked: true, carrying recover — the same pharos issue link … command the failure above handed over. Finishing that link is the repair; a second work item is not |
| two work items claim one issue | exit 3, both named, and no recover — picking one would be inventing an answer to the question this feature exists to answer |
The middle row is the one that used to be a duplicate. The marker is the index
and in that state it was never posted, so before creating anything adopt also
asks this machine — an adoption is written to ~/.config/pharos/half-links.json
between the create and the comment, and removed when the comment lands — and
then the board, narrowing on [System.HyperLinkCount] and matching the
relation URLs client-side, which is the only way round Azure DevOps having no
query over them. The board is the stronger of the two — it sees a half-link
left on another machine — and the local record covers what it cannot: a board
query that is refused, a half-link older than that scan's cap, and a refusal
that costs no round trip. Every run reports boardCheck, because "the board
says nobody has adopted this" and "the board could not be asked" are different
facts and only one of them means the check happened.
trail is read-only and answers from either end — the output shape does not
depend on which you passed. Read evidence: ["hyperlink","marker"] is a
complete link, and either one alone is a link that only half exists.
say — one message, two audiences
pharos issue say contoso/widgets#45 --file reply.md
pharos issue say contoso/widgets#45 --stdin --summary "Fixed in 1.4.2; asked for a crash log"| | GitHub | Azure DevOps | |---|---|---| | who reads it | the reporter, who does not have an Azure DevOps account and never will | the team | | what they get | the full explanation, in their register | a summary, and the URL of the comment carrying the rest |
The asymmetry is the verb. The same words in both places is gh issue
comment followed by pharos comment add, and neither of those knows the other
happened. Without --summary the board gets the first paragraph cut to 200
characters — a guess, and allowed to be one only because the comment's URL
travels with it. The board is never the record of what was said; GitHub is.
The loop guard keys on content, never on sender.login. GitHub events carry
an actor and Azure DevOps events do not, which makes the actor look like the easy
answer and is the trap: every client writes with its own gh credential, so
sender.login == me is also true of the human typing into the web UI, and
suppressing on it silently swallows their real comments. So the guard is the
pharos:v1 trailer, in two halves:
- Every comment
sayposts carries it, because the read sweep is only safe ifisPharosAuthoredis total. One unmarked Pharos comment anywhere is one the sweep reads as a human's, and the loop that starts from it does not stop. - A message that already carries one is refused. That is Pharos's own output being handed back — a mirrored body, a copy-paste, a script re-posting what it read — and it is the loop starting one step before any sweep could see it. The macOS app's queue refuses exactly the same thing.
GitHub is written first here, the opposite of adopt, because the board's
comment carries the GitHub comment's URL and that does not exist until the POST
returns. The work item is read before anything is published, since the GitHub
half lands on a repository that may not be ours. If the board half then fails:
{ "error": { "halfSaid": true, "wrote": ["github-comment"],
"github": { "commentUrl": "https://github.com/…#issuecomment-950" },
"recover": "pharos comment add 4821 --stdin", "adoComment": "…" } }Do not re-run it. A comment is an append, so a second say posts a second
public comment; the error carries the exact text and the command that writes the
half that is missing.
say starts from the issue end only. A work item may hyperlink several issues,
so that end names no single reporter — pharos issue trail <id> is how you find
out which issue you meant.
Pull requests are out of scope for v1 and are refused by name rather than
half-supported: on GitHub's API every PR is also an issue, so adopt on a PR
number would otherwise mirror something the rest of this does not model.
drift — the report no other tool can produce
pharos issue drift
pharos issue drift --repo contoso/widgets --limit 50gh can list issues; Azure DevOps can list work items; neither holds both
sides, so neither can say these two disagree. A link nobody checks decays
quietly — an issue gets closed on GitHub and the board still shows the work in
progress, or a work item is marked done and the reporter, who has no Azure
DevOps account and never will, is still waiting.
Four kinds, because they want four different actions:
| kind | what it means |
|---|---|
| state | both ends exist and disagree about whether the work is finished |
| missing-issue | the work item names an issue GitHub does not have |
| one-sided | the board has the link and the issue does not — what a failed adopt leaves. Carries the pharos issue link … that repairs it |
| unreadable | the GitHub end could not be read, so nothing about this pair is known |
It scans links, not issues. An unadopted issue is not drift — on a repo with
four hundred of them, counting "not linked" as a problem buries three real
findings under three hundred and eighty-eight rows of noise. What is missing
from the bridge is backfill's question; what is wrong inside it is this one.
"Finished" is read from the project, never guessed. A process template
renames every state, and Resolved is terminal on a Bug and open on a User
Story — so each work item is judged against its own type's categories and the
report says which source it used. When the catalogue cannot be read the stock
names are used and said out loud, because a row is uninterpretable without
knowing what was counted as finished.
An unreadable repository is information, not a fault, and never fails the command: a work item can hyperlink a repo somebody else adopted on a machine configured differently.
backfill — the adoption path, for the repo everybody actually has
pharos issue backfill contoso/widgets # previews, then REFUSES
pharos issue backfill contoso/widgets --dry-run # the same preview, exit 0
pharos issue backfill contoso/widgets --limit 25 --max-writes 50 --yes
pharos issue backfill contoso/widgets --label bug --parent 39 --yesWithout a bulk route the bridge only works on a greenfield repository, which is not where anybody is. A team with four hundred open issues cannot adopt them one command at a time.
Deciding what is already adopted costs two requests, not four hundred. The
board is the enumerable side: a work item carries its Hyperlink relations on
its own row, so one WIQL and one workitemsbatch return every link at once.
GitHub cannot answer the equivalent question at all — the marker lives in a
comment, so asking costs a request per issue. That subtraction is correct
because adopt writes the Azure DevOps end first, so the half-state it can
leave is a hyperlink with no comment and never the reverse. The mirror image is
still possible if somebody deletes a relation by hand, so the marker is re-read
per issue at adoption time — on the handful being written — and an issue
that turns out to be claimed already is skipped rather than adopted twice.
It is resumable and needs no state file to be. The link is the progress record: a second run reads the board again, finds the ones the first run adopted already linked, and carries on. Candidates are ordered by issue number ascending so that is true in the obvious way as well as the technical one.
The write budget is the throttle, on purpose. Each adoption is two writes, so four hundred issues is eight hundred against a default cap of twenty. That cost is worked out before anything is written and a run that cannot finish is refused, naming both ways forward — rather than stopping at write nineteen, which is the outcome a caller can reason about least.
A bare invocation previews and refuses (exit 3); --dry-run is the same
preview at exit 0. The preview names the repository, the gh account it would
be reached as, and the Azure DevOps organisation and project the work items
would land on. Nothing in the design pairs a repo with a board, and a
mis-aimed adopt is one work item where a mis-aimed backfill --yes is four
hundred — each with a public comment naming a board its reporter has nothing to
do with.
--state defaults to open: a closed issue is history, and adopting it creates
a work item that is finished before anybody looks at it.
close — the ending of the trail
pharos issue close 4821 # …or close contoso/widgets#45
pharos issue close 4821 --to Removed --reason not-planned
pharos issue close 4821 --file release-note.mdCloses both ends, comments on each pointing at the other, then re-reads
both and reports what actually moved. Two 200s are not proof: a workflow rule
can refuse a transition on a field the API happily accepted, so verified is
what was there afterwards rather than what was sent.
It does not compose Closes #45 / Fixes AB#123 into a PR body. Those fire
on a PR merge and pull requests are out of scope for v1 — so there would be
nothing to compose into and nothing to verify.
There is no hardcodable Closed. The state written comes from the project's
own categories: exactly one terminal state for that work item type is used,
several is a refusal naming them and --to, because Done and Removed are
both finished and mean opposite things. If the catalogue cannot be read at all it
refuses rather than guessing — drift degrades to the stock names because a
misclassified row is a question somebody can dismiss, and the same guess here
would be a write.
--to, not --state: --state already means open|closed|all on backfill,
and one flag with two meanings across two verbs of one command is a bug that only
shows up when somebody runs it.
Closing an already-closed pair writes nothing and exits 0. A work item linking
several issues is refused unless --all says so — "close 4821" should not read
as "close four strangers' issues".
Configuration
| | |
|---|---|
| ADO_ORG | your organisation — the first path component of your dev.azure.com URL |
| ADO_PROJECT | the project inside it |
| ADO_PAT | a Personal Access Token |
From the environment, and nowhere else. No keychain, no credential helper, no config file — that is what lets this run headless, in CI, or under an agent with nobody there to answer a prompt. There is deliberately no built-in organisation either: a default would let a misconfigured run succeed quietly against somebody else's board.
Scopes are separate and both are needed for full use: vso.work_write for work
items, vso.wiki_write for the wiki.
Every person uses their own token. Board attribution is per-person, and a shared token makes everyone's work read as one account.
GitHub, which follows a different rule for a measured reason
The rule above is about ADO_PAT and it has not moved. GitHub is a separate
thing, and it needs two facts that an environment variable cannot carry.
The credential comes from gh, so there is no fourth secret to manage.
gh auth token --user <account> resolves it and the API calls are ordinary
HTTPS from there; revoking GitHub access never touches the Azure DevOps token.
Pass --user or nothing else: a bare gh auth token returns whatever GH_TOKEN
happens to be exported and ignores the keyring entirely, so an exported token in
somebody's shell would silently become the identity of a sweep.
The account is bound per repository, in ~/.config/pharos/repos.json.
pharos setup --repo contoso/widgets --gh-account alisina-tibataBoth flags or neither — a repository is a (repo, account) pair, and there
is deliberately no fallback to whichever account gh has active. Azure DevOps
binds a project to one GitHub identity; gh holds many at once with one active
machine-wide, and on the machine this was measured on the active account was not
the one owning the repository behind the project being worked. The failure is
not a clean error either: two accounts can each see a repository of the same
name, so the wrong one answers 200 for a different repository. That is what
pharos doctor --verify compares owners for.
$PHAROS_REPOS_FILE and $XDG_CONFIG_HOME both move the file. gh is optional
— without it every pharos issue verb is refused by name with the reason, and
the Azure DevOps verbs are untouched.
And it runs in the other direction too: a verb whose target is a GitHub issue
needs no ADO_PAT. pharos comment list contoso/widgets#45 reads over gh's
credential and touches Azure DevOps at no point, so it works on a machine that
has never had a PAT. The Azure DevOps configuration is resolved when something
actually needs Azure DevOps, and not before — which means the kind: "config"
error naming ADO_PAT now arrives only from a verb that would have used it.
pharos issue is the exception and honestly so: it writes to both platforms in
one invocation, so it needs both credentials.
half-links.json sits beside it, and it is the only thing this CLI remembers
between runs. An adoption is written there between the work item create and
the GitHub comment and removed when the comment lands, so what survives is
exactly the set of adoptions that half-happened — the state neither platform can
be asked about, and the one a retry follows. It is never consulted for what a
link is: the marker and the hyperlink are read fresh every time, and an entry
is checked against Azure DevOps before it is believed. Delete it and you lose
nothing but one guard; $PHAROS_HALF_LINKS_FILE moves it.
Checking a machine
pharos doctor # three tiers, with a stable id per check
pharos doctor --verify # prove the credentials with real calls
pharos setup --install gh # or ask|all|libreoffice,poppler,…doctor is read-only and works before anything is configured, which is what
makes it the right first move when something behaves oddly. It prints the PATH
it searched, because a GUI-launched app inherits a minimal one and would
otherwise be told a fully provisioned machine is bare.
It is offline apart from one probe, and that probe is named rather than glossed:
gh auth status validates every token against github.com, with no flag to
stop it, so it runs only when gh is installed. Measured on gh 2.87.3, it also
reports an unreachable host as The token in keyring is invalid — a
connectivity fault worded as a credential fault. gh-auth therefore never tells
you that you are signed out; it reports both causes, because from here they are
genuinely indistinguishable.
Requirements
Node 22 or later. gh for the GitHub bridge, and nothing else is required.
