@retasc/cli 1.44.0 → 1.46.0
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.
- package/CHANGELOG.md +48 -0
- package/dist/commands/doctor.js +32 -4
- package/dist/commands/setup.js +8 -0
- package/dist/lib/gitRepo.js +141 -0
- package/dist/lib/harness.js +100 -4
- package/dist/lib/keystore.js +64 -1
- package/dist/lib/skill.js +110 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,54 @@ release commits and the issues they reference.
|
|
|
6
6
|
|
|
7
7
|
Dates are the npm publish date. Each entry names the RTSC issue behind it.
|
|
8
8
|
|
|
9
|
+
## 1.46.0 (2026-09-08)
|
|
10
|
+
|
|
11
|
+
- **RTSC-855** — `retasc doctor` and `retasc setup` now say whether the **Retasc Agent
|
|
12
|
+
Skill** is installed on this machine, and print the one command that installs it.
|
|
13
|
+
|
|
14
|
+
Wiring the MCP server gives an agent the ability to claim work. The skill is what tells
|
|
15
|
+
it what a lease is, that a claim needs its own worktree, what a checkpoint is for, and
|
|
16
|
+
how a handoff into review works. Nothing connected the two, so an agent could be fully
|
|
17
|
+
wired and still have the tools without the manual, and no surface anywhere would
|
|
18
|
+
mention it. The server cannot fill the gap: MCP carries a bearer token and no
|
|
19
|
+
filesystem, so only something running on the machine can look.
|
|
20
|
+
|
|
21
|
+
The hard part is not finding the file, it is not being wrong about it. A false "not
|
|
22
|
+
installed" shown to somebody who followed our own instructions teaches them to distrust
|
|
23
|
+
everything else `doctor` says, so the check knows all three routes a skill really
|
|
24
|
+
arrives by: global (`~/.claude/skills` and each harness's equivalent), project (the
|
|
25
|
+
`skills` installer's DEFAULT scope, which lands in the working directory), and, for
|
|
26
|
+
Claude Code, an installed plugin's own folder, since `/plugin install retasc@retasc` is
|
|
27
|
+
one of the routes our public README offers and it writes none of the above. It honours
|
|
28
|
+
`CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `GROK_HOME` for the same reason. The printed
|
|
29
|
+
command carries `-g` so it installs where the check looks. A harness whose convention
|
|
30
|
+
we cannot verify is skipped rather than accused, one harness holding the skill silences
|
|
31
|
+
the nudge for the rest, absence is reported with `ℹ` rather than `✗`, and neither
|
|
32
|
+
command changes its exit code over any of it.
|
|
33
|
+
|
|
34
|
+
## 1.45.0 (2026-09-08)
|
|
35
|
+
|
|
36
|
+
- **RTSC-862** — a binding now covers every **git worktree** of the repo it was made in.
|
|
37
|
+
|
|
38
|
+
`findBindingByPath` walks up from the current directory looking for a bound folder and
|
|
39
|
+
stops at the first `.git`, which is what stops `retasc bind` in `~` from silently
|
|
40
|
+
binding every project beneath it. But `.git` is a FILE at the root of every worktree,
|
|
41
|
+
so that stop fired on the first step inside one: every worktree resolved to nothing,
|
|
42
|
+
with no error and no hint, and the tools were simply absent. Repos whose workflow
|
|
43
|
+
creates a worktree per task (this one included) hit it constantly, and the rational
|
|
44
|
+
workaround — paste a raw key into a config that works everywhere — silently costs the
|
|
45
|
+
watchdog, session rows, transcripts and the folder name.
|
|
46
|
+
|
|
47
|
+
"Same repository" is decided by git's common dir, the one directory every worktree
|
|
48
|
+
shares, read from disk rather than by shelling out to git. A sibling repo checked out
|
|
49
|
+
inside a bound directory still resolves to nothing, so the cross-org property RTSC-91
|
|
50
|
+
exists for is untouched. Binding a worktree deliberately still wins over its repo's
|
|
51
|
+
binding.
|
|
52
|
+
|
|
53
|
+
`retasc doctor` says when a folder resolved this way, instead of the old warning that
|
|
54
|
+
the id "was bound at a different folder" — advice which, for a worktree, would have
|
|
55
|
+
minted a second key and a second agent for one repo.
|
|
56
|
+
|
|
9
57
|
## 1.44.0 (2026-09-08)
|
|
10
58
|
|
|
11
59
|
- **RTSC-861** — the proxy now tells Retasc which CLI version it is. It rides on
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
|
|
3
3
|
import { getBinding } from "../lib/keystore.js";
|
|
4
|
+
import { gitCommonDir } from "../lib/gitRepo.js";
|
|
5
|
+
import { detectHarnesses } from "../lib/harness.js";
|
|
4
6
|
import { runsOk } from "../lib/launcher.js";
|
|
7
|
+
import { skillLines, skillReport } from "../lib/skill.js";
|
|
5
8
|
import { clean } from "../lib/text.js";
|
|
6
9
|
// RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
|
|
7
10
|
// safely bound. The question a human actually has is "which org/project does
|
|
@@ -150,9 +153,26 @@ export async function doctorAction() {
|
|
|
150
153
|
// may have reused someone else's id — surface it rather than silently use it.
|
|
151
154
|
const entry = getBinding(local.workspaceId);
|
|
152
155
|
if (entry?.boundPath && entry.boundPath !== cwd) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
+
// RTSC-862 — a WORKTREE of the bound repo is the expected case now, not a
|
|
157
|
+
// problem, so it must not be reported as one. Before this it read "bound at a
|
|
158
|
+
// different folder… re-run bind", which is exactly the wrong advice: re-binding
|
|
159
|
+
// a worktree mints a second key and a second agent for one repo, which is how
|
|
160
|
+
// people ended up with three.
|
|
161
|
+
const repo = gitCommonDir(cwd);
|
|
162
|
+
const sameRepo = !!repo && gitCommonDir(entry.boundPath) === repo;
|
|
163
|
+
if (sameRepo) {
|
|
164
|
+
// Deliberately not "this folder is a worktree of it": the relation is
|
|
165
|
+
// symmetric, so that sentence is false when the cwd is the main checkout and
|
|
166
|
+
// the BOUND folder is the worktree, which is an ordinary way round.
|
|
167
|
+
console.log(` ℹ same repository as the bound folder:\n` +
|
|
168
|
+
` ${clean(entry.boundPath)}\n` +
|
|
169
|
+
` Worktrees share one binding, so this is expected. Nothing to do.`);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
warn(`this workspace id was bound at a different folder:\n` +
|
|
173
|
+
` ${entry.boundPath}\n` +
|
|
174
|
+
` If you didn't move this repo, re-run \`retasc bind\` to mint a key for THIS folder.`);
|
|
175
|
+
}
|
|
156
176
|
}
|
|
157
177
|
}
|
|
158
178
|
else if (local.legacy) {
|
|
@@ -199,7 +219,15 @@ export async function doctorAction() {
|
|
|
199
219
|
` own, so issues can land in the wrong project.\n` +
|
|
200
220
|
` Fix: claude mcp remove -s user retasc`);
|
|
201
221
|
}
|
|
202
|
-
// 3)
|
|
222
|
+
// 3) Does the agent that will use this binding have the manual? (RTSC-855)
|
|
223
|
+
//
|
|
224
|
+
// A machine fact rather than a folder fact, so it comes after both folder checks.
|
|
225
|
+
// It is still a check, not a footnote: everything above proves an agent can REACH
|
|
226
|
+
// Retasc, and this is the one that says whether it will know what to do when it
|
|
227
|
+
// gets there. Absence never touches the exit code, and `doctorAction` sets none.
|
|
228
|
+
for (const line of skillLines(skillReport(detectHarnesses())))
|
|
229
|
+
console.log(line);
|
|
230
|
+
// 4) Ambient context, not a verdict on this folder — so it trails the checks
|
|
203
231
|
// rather than framing them, and on macOS it prints nothing at all.
|
|
204
232
|
const note = platformNote();
|
|
205
233
|
if (note)
|
package/dist/commands/setup.js
CHANGED
|
@@ -18,6 +18,7 @@ import { wireClaudeHook, hookLauncher } from "../lib/sessionHook.js";
|
|
|
18
18
|
import { AUTO_WORKSPACE } from "../lib/keystore.js";
|
|
19
19
|
import { HARNESSES, detectHarnesses, tildePath } from "../lib/harness.js";
|
|
20
20
|
import { resolveLauncher, launcherNote } from "../lib/launcher.js";
|
|
21
|
+
import { skillLines, skillReport } from "../lib/skill.js";
|
|
21
22
|
import { VERSION } from "../version.js";
|
|
22
23
|
import { card } from "../lib/card.js";
|
|
23
24
|
/** The one entry every harness gets: the watchdog proxy, resolving its own folder. */
|
|
@@ -49,6 +50,7 @@ export function runSetup(opts) {
|
|
|
49
50
|
wired: [],
|
|
50
51
|
failed: [],
|
|
51
52
|
absent: known.filter((h) => !presentIds.has(h.id)).map((h) => h.label),
|
|
53
|
+
skill: skillReport(present),
|
|
52
54
|
};
|
|
53
55
|
for (const h of present) {
|
|
54
56
|
const outcome = h.install(entry);
|
|
@@ -98,6 +100,12 @@ export function printSetup(r) {
|
|
|
98
100
|
}
|
|
99
101
|
if (r.absent.length)
|
|
100
102
|
console.log(` Not on this machine: ${r.absent.join(", ")}.`);
|
|
103
|
+
// RTSC-855 — next to the harness rows, because it is the same kind of fact: what this
|
|
104
|
+
// machine has, and what it is still missing. `setup` wires the tools; the skill is the
|
|
105
|
+
// document that tells an agent what to do with them, and nothing else on this receipt
|
|
106
|
+
// would ever mention that it is absent. Never a failure, and never installed for you.
|
|
107
|
+
for (const line of skillLines(r.skill))
|
|
108
|
+
console.log(line);
|
|
101
109
|
console.log("\nThese entries carry no key and name no project, so they are correct in every folder.\n" +
|
|
102
110
|
"Run `retasc bind` in a project to say which org it belongs to, then start your agent\n" +
|
|
103
111
|
"there (or restart it if it's already open).");
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Which repository is this directory part of? (RTSC-862.)
|
|
5
|
+
*
|
|
6
|
+
* The answer is git's COMMON DIR — the one directory every worktree of a repository
|
|
7
|
+
* shares. It is the only thing that makes "the same repo" a decidable question:
|
|
8
|
+
*
|
|
9
|
+
* • normal clone `~/retasc` → `~/retasc/.git`
|
|
10
|
+
* • its worktree `~/retasc-rtsc-862` → `~/retasc/.git` (same repo)
|
|
11
|
+
* • bare layout `~/retasc/.bare` with
|
|
12
|
+
* worktrees `main`, `rtsc-854` → `~/retasc/.bare` (same repo)
|
|
13
|
+
* • an unrelated clone nested inside → its own `.git` (different repo)
|
|
14
|
+
*
|
|
15
|
+
* WHY NOT SHELL OUT. `git rev-parse --git-common-dir` answers this directly, and this
|
|
16
|
+
* function deliberately does not call it. It runs at the start of every proxy session,
|
|
17
|
+
* which is every MCP server startup in every harness, and spawning a process there
|
|
18
|
+
* buys a fork+exec on a path whose whole job is to resolve a key in milliseconds. The
|
|
19
|
+
* on-disk format this reads is stable and documented (gitrepository-layout).
|
|
20
|
+
*
|
|
21
|
+
* TRUST MODEL, since this decides which ORG a folder acts in — so a `.git` file is now a
|
|
22
|
+
* routing input, not merely a stop marker, and it is content that can arrive in an
|
|
23
|
+
* archive. git refuses to track a path named `.git`, so this cannot come from a clone,
|
|
24
|
+
* but a tarball, a vendored bundle or a scaffold generator can carry one.
|
|
25
|
+
*
|
|
26
|
+
* So a worktree pointer is only believed when it is RECIPROCAL, the way git's own
|
|
27
|
+
* worktree bookkeeping is: `<common>/worktrees/<name>/gitdir` must point back at the
|
|
28
|
+
* `.git` file we started from. A hand-written `gitdir:` line at someone else's repository
|
|
29
|
+
* fails that check and resolves to nothing, where before this it would have claimed that
|
|
30
|
+
* repository's binding. Verified both ways.
|
|
31
|
+
*
|
|
32
|
+
* The check is git's own invariant rather than an invention, so it costs nothing on a
|
|
33
|
+
* real worktree and cannot drift from what git considers linked. What is deliberately NOT
|
|
34
|
+
* attempted is out-guessing git in general: if someone can write `.git` into a directory
|
|
35
|
+
* you work in, they already own your hooks and config there, which is a larger problem
|
|
36
|
+
* than a binding. The narrow thing we owe is never to resolve ACROSS repositories git
|
|
37
|
+
* considers distinct — which is also why submodules, whose gitdirs carry no `commondir`,
|
|
38
|
+
* resolve to themselves.
|
|
39
|
+
*
|
|
40
|
+
* NEVER THROWS. A malformed `.git` file, a dangling gitdir, a permissions error: all
|
|
41
|
+
* return undefined. Resolution failing means "no binding found", which the caller
|
|
42
|
+
* already handles; a startup crash in the proxy takes the agent's tools away entirely,
|
|
43
|
+
* which is far worse than a folder that needs `retasc bind` run again.
|
|
44
|
+
*/
|
|
45
|
+
export function gitCommonDir(dir) {
|
|
46
|
+
try {
|
|
47
|
+
const dotGit = join(dir, ".git");
|
|
48
|
+
if (!existsSync(dotGit))
|
|
49
|
+
return undefined;
|
|
50
|
+
// A normal clone: `.git` is a directory and IS the common dir. A worktree's private
|
|
51
|
+
// gitdir is also a directory and looks identical from here, which is what the
|
|
52
|
+
// `commondir` probe below distinguishes — git writes that file only in the private
|
|
53
|
+
// one. `GIT_DIR` pointed at a worktree gitdir lands here too.
|
|
54
|
+
if (statSync(dotGit).isDirectory())
|
|
55
|
+
return followCommonDir(dotGit);
|
|
56
|
+
// A worktree: `.git` is a FILE holding `gitdir: <path to the private gitdir>`.
|
|
57
|
+
// FIRST line only, as real git reads it — an `m` flag here would accept the pointer
|
|
58
|
+
// anywhere in the file, widening what an archive can smuggle in.
|
|
59
|
+
const first = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0];
|
|
60
|
+
const m = /^gitdir:\s*(.+?)\s*$/.exec(first);
|
|
61
|
+
if (!m)
|
|
62
|
+
return undefined;
|
|
63
|
+
const gitdir = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
|
|
64
|
+
if (!existsSync(gitdir))
|
|
65
|
+
return undefined; // dangling: the worktree was pruned
|
|
66
|
+
if (!pointsBackAt(gitdir, dotGit))
|
|
67
|
+
return undefined;
|
|
68
|
+
return followCommonDir(gitdir);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Does this private gitdir agree that it belongs to this worktree?
|
|
76
|
+
*
|
|
77
|
+
* git keeps the link in both directions: the worktree's `.git` file names the private
|
|
78
|
+
* gitdir, and `<gitdir>/gitdir` names the worktree's `.git` file back. Only the first
|
|
79
|
+
* direction is writable by whoever plants a folder, so checking the second is what turns
|
|
80
|
+
* "a file claims to belong to your repo" into "your repo agrees".
|
|
81
|
+
*
|
|
82
|
+
* A gitdir with no `gitdir` file is not a worktree's — a submodule's private dir, or the
|
|
83
|
+
* bare/main directory reached some other way — so this returns false and the caller
|
|
84
|
+
* resolves nothing, which is the safe direction.
|
|
85
|
+
*/
|
|
86
|
+
function pointsBackAt(gitdir, dotGitFile) {
|
|
87
|
+
try {
|
|
88
|
+
const back = join(gitdir, "gitdir");
|
|
89
|
+
if (!existsSync(back))
|
|
90
|
+
return false;
|
|
91
|
+
const claimed = readFileSync(back, "utf8").trim();
|
|
92
|
+
if (!claimed)
|
|
93
|
+
return false;
|
|
94
|
+
return realish(claimed) === realish(dotGitFile);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a gitdir to the repository's common dir.
|
|
102
|
+
*
|
|
103
|
+
* A worktree's private gitdir carries a `commondir` file (usually `../..`) pointing at
|
|
104
|
+
* the shared one. A main `.git` has no such file and is already the common dir. Reading
|
|
105
|
+
* the file rather than assuming `../..` is what makes the bare layout work, where the
|
|
106
|
+
* shared directory is `.bare` rather than `.git`.
|
|
107
|
+
*/
|
|
108
|
+
function followCommonDir(gitdir) {
|
|
109
|
+
const marker = join(gitdir, "commondir");
|
|
110
|
+
if (!existsSync(marker))
|
|
111
|
+
return realish(gitdir);
|
|
112
|
+
const rel = readFileSync(marker, "utf8").trim();
|
|
113
|
+
if (!rel)
|
|
114
|
+
return realish(gitdir);
|
|
115
|
+
return realish(isAbsolute(rel) ? rel : resolve(gitdir, rel));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a path the same way `gitCommonDir` resolves the ones it returns.
|
|
119
|
+
*
|
|
120
|
+
* Exported because comparing an unresolved path against a resolved one is a silent
|
|
121
|
+
* mismatch on macOS, where `/var` is a symlink to `/private/var` and a temp dir spells
|
|
122
|
+
* itself both ways. Anything compared against this module's output comes through here.
|
|
123
|
+
*/
|
|
124
|
+
export function realPath(p) {
|
|
125
|
+
return realish(p);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Resolve for comparison. `realpathSync` matters on macOS, where `/tmp` is a symlink to
|
|
129
|
+
* `/private/tmp` and two spellings of one directory would otherwise never match — the
|
|
130
|
+
* same reason `findBindingByPath` resolves both sides before comparing.
|
|
131
|
+
*/
|
|
132
|
+
function realish(p) {
|
|
133
|
+
try {
|
|
134
|
+
return realpathSync(resolve(p));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// A path that does not exist cannot be realpath'd. Comparing the lexical form is
|
|
138
|
+
// still better than giving up, and the callers only ever compare two of these.
|
|
139
|
+
return resolve(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
package/dist/lib/harness.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import { dirname, join } from "node:path";
|
|
29
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
30
30
|
/**
|
|
31
31
|
* The home directory harness configs are resolved under.
|
|
32
32
|
*
|
|
@@ -48,6 +48,38 @@ export function tildePath(p) {
|
|
|
48
48
|
const h = home();
|
|
49
49
|
return p.startsWith(h + "/") ? `~${p.slice(h.length)}` : p;
|
|
50
50
|
}
|
|
51
|
+
// --- skills directories -----------------------------------------------------
|
|
52
|
+
//
|
|
53
|
+
// Not derived from theory, same rule as the rest of this registry: every path below is a
|
|
54
|
+
// directory the `skills` installer (agentskills.io, the tool `SKILL_INSTALL_CMD` runs)
|
|
55
|
+
// reads for that harness, taken from its published agent table rather than guessed at. If
|
|
56
|
+
// a harness ever grew a convention we could not verify, the honest entry would be an empty
|
|
57
|
+
// list. "We do not know where yours lives" is a finding a human can act on, a made-up path
|
|
58
|
+
// is not.
|
|
59
|
+
//
|
|
60
|
+
// TWO scopes, because the installer has two and defaults to the one a global-only check
|
|
61
|
+
// would never see (`installGlobally = options.global ?? false`, and its interactive prompt
|
|
62
|
+
// offers Project first). A check that read only the home directory would call the skill
|
|
63
|
+
// missing for everybody who ran the install command exactly as printed, forever.
|
|
64
|
+
//
|
|
65
|
+
// The harness's own home variable is honoured here even though `configPath()` does not
|
|
66
|
+
// honour it. That asymmetry is deliberate: this path only READS, so resolving the same
|
|
67
|
+
// home the harness itself resolves can at worst find the skill somewhere true, whereas
|
|
68
|
+
// `configPath()` WRITES and shares its answer with subprocesses. RETASC_HOME still
|
|
69
|
+
// outranks everything, exactly as in `opencodeConfig()`, or the test override would stop
|
|
70
|
+
// protecting the developer's real home the moment one of these variables is set.
|
|
71
|
+
/** A harness's own config home: the test override first, then its documented variable,
|
|
72
|
+
* then the default under `~`. */
|
|
73
|
+
function harnessHome(envVar, dir) {
|
|
74
|
+
if (process.env.RETASC_HOME)
|
|
75
|
+
return join(home(), dir);
|
|
76
|
+
return process.env[envVar]?.trim() || join(home(), dir);
|
|
77
|
+
}
|
|
78
|
+
/** `<global skills dir>` plus the project-scope folder this harness reads in the working
|
|
79
|
+
* directory. Both are real install targets, so both answer "is the skill here". */
|
|
80
|
+
function skillDirs(globalDir, projectDir) {
|
|
81
|
+
return [globalDir, join(process.cwd(), ...projectDir.split("/"))];
|
|
82
|
+
}
|
|
51
83
|
export const SERVER_NAME = "retasc";
|
|
52
84
|
// --- shared helpers ---------------------------------------------------------
|
|
53
85
|
/**
|
|
@@ -134,11 +166,12 @@ export function tomlBlock(entry) {
|
|
|
134
166
|
env,
|
|
135
167
|
].join("\n");
|
|
136
168
|
}
|
|
137
|
-
function tomlHarness(id, label, path, bin) {
|
|
169
|
+
function tomlHarness(id, label, path, bin, skills) {
|
|
138
170
|
return {
|
|
139
171
|
id,
|
|
140
172
|
label,
|
|
141
173
|
configPath: path,
|
|
174
|
+
skillDirs: () => skillDirs(join(harnessHome(skills.home, skills.dir), "skills"), skills.project),
|
|
142
175
|
detect: () => existsSync(path()) || onPath(bin),
|
|
143
176
|
install(entry) {
|
|
144
177
|
const p = path();
|
|
@@ -335,6 +368,53 @@ export function jsonServerValue(entry) {
|
|
|
335
368
|
return JSON.stringify({ command: entry.command, args: entry.args, env: entry.env }, null, 2);
|
|
336
369
|
}
|
|
337
370
|
// --- Claude Code ------------------------------------------------------------
|
|
371
|
+
/**
|
|
372
|
+
* The `skills/` folder of every plugin Claude Code has installed (RTSC-855).
|
|
373
|
+
*
|
|
374
|
+
* `/plugin install retasc@retasc` is one of the three install routes our public skills
|
|
375
|
+
* README offers, and it does NOT write into `~/.claude/skills`. The plugin is unpacked
|
|
376
|
+
* under `~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/` and its skills are
|
|
377
|
+
* read from there. So a check that only looked in the skills folder would tell exactly
|
|
378
|
+
* the people who followed our own instructions that they had not.
|
|
379
|
+
*
|
|
380
|
+
* Read from the manifest rather than globbed, because the manifest is what Claude Code
|
|
381
|
+
* itself acts on: a stale directory left behind by an uninstall is not an installed
|
|
382
|
+
* skill, and enumerating the cache would count it as one. Best effort throughout, because
|
|
383
|
+
* this feeds a nudge and a manifest we cannot parse must never become a hard failure in
|
|
384
|
+
* `doctor`.
|
|
385
|
+
*/
|
|
386
|
+
function claudePluginSkillDirs() {
|
|
387
|
+
try {
|
|
388
|
+
const manifest = join(harnessHome("CLAUDE_CONFIG_DIR", ".claude"), "plugins", "installed_plugins.json");
|
|
389
|
+
const parsed = JSON.parse(readIfExists(manifest) || "{}");
|
|
390
|
+
const plugins = parsed?.plugins;
|
|
391
|
+
if (!plugins || typeof plugins !== "object")
|
|
392
|
+
return [];
|
|
393
|
+
const dirs = [];
|
|
394
|
+
for (const installs of Object.values(plugins)) {
|
|
395
|
+
for (const i of Array.isArray(installs) ? installs : []) {
|
|
396
|
+
// Absolute only. A relative `installPath` would resolve against the CURRENT
|
|
397
|
+
// DIRECTORY, so running `retasc doctor` inside any repo that happens to contain
|
|
398
|
+
// `skills/retasc/SKILL.md` (this one does) would report the skill installed.
|
|
399
|
+
// Claude Code writes absolute paths, so nothing legitimate is dropped.
|
|
400
|
+
// User scope only. A plugin installed for one project would otherwise make
|
|
401
|
+
// `doctor` report the skill present in every OTHER folder, where the agent
|
|
402
|
+
// cannot load it. A false "installed" is the worse error of the two: it
|
|
403
|
+
// produces silence instead of the nudge this check exists to give.
|
|
404
|
+
const scope = i?.scope;
|
|
405
|
+
if (scope !== undefined && scope !== "user")
|
|
406
|
+
continue;
|
|
407
|
+
const p = i?.installPath;
|
|
408
|
+
if (typeof p === "string" && isAbsolute(p))
|
|
409
|
+
dirs.push(join(p, "skills"));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return dirs;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
338
418
|
/**
|
|
339
419
|
* Claude Code is the one harness with a real CLI for this, and `claude mcp add` is
|
|
340
420
|
* the ONLY sanctioned way to edit `~/.claude.json`: a running Claude Code rewrites
|
|
@@ -351,6 +431,10 @@ const claudeCode = {
|
|
|
351
431
|
id: "claude-code",
|
|
352
432
|
label: "Claude Code",
|
|
353
433
|
configPath: () => join(home(), ".claude.json"),
|
|
434
|
+
skillDirs: () => [
|
|
435
|
+
...skillDirs(join(harnessHome("CLAUDE_CONFIG_DIR", ".claude"), "skills"), ".claude/skills"),
|
|
436
|
+
...claudePluginSkillDirs(),
|
|
437
|
+
],
|
|
354
438
|
// A test home means "do not touch this machine's real config". `claude mcp add`
|
|
355
439
|
// writes ~/.claude.json wherever HOME points, so the override has to gate DETECTION,
|
|
356
440
|
// not just the path: an undetected harness is never installed into.
|
|
@@ -404,6 +488,7 @@ const cursor = {
|
|
|
404
488
|
id: "cursor",
|
|
405
489
|
label: "Cursor",
|
|
406
490
|
configPath: () => join(home(), ".cursor", "mcp.json"),
|
|
491
|
+
skillDirs: () => skillDirs(join(home(), ".cursor", "skills"), ".agents/skills"),
|
|
407
492
|
detect: () => existsSync(join(home(), ".cursor")) || onPath("cursor-agent"),
|
|
408
493
|
install(entry) {
|
|
409
494
|
const p = join(home(), ".cursor", "mcp.json");
|
|
@@ -438,6 +523,7 @@ function addCommandHarness(opts) {
|
|
|
438
523
|
id: opts.id,
|
|
439
524
|
label: opts.label,
|
|
440
525
|
configPath: opts.configPath,
|
|
526
|
+
skillDirs: () => skillDirs(join(dirname(opts.configPath()), "skills"), opts.projectSkillDir),
|
|
441
527
|
// Gated on RETASC_HOME for the same reason Claude Code is: this shells out to a
|
|
442
528
|
// command that resolves the REAL home, so a test home has to stop it at DETECTION.
|
|
443
529
|
// An undetected harness is never installed into.
|
|
@@ -488,6 +574,7 @@ const opencode = addCommandHarness({
|
|
|
488
574
|
label: "OpenCode",
|
|
489
575
|
bin: "opencode",
|
|
490
576
|
configPath: opencodeConfig,
|
|
577
|
+
projectSkillDir: ".agents/skills",
|
|
491
578
|
args: (entry) => [
|
|
492
579
|
"mcp", "add", SERVER_NAME,
|
|
493
580
|
...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
|
|
@@ -513,6 +600,7 @@ const gemini = addCommandHarness({
|
|
|
513
600
|
label: "Gemini CLI",
|
|
514
601
|
bin: "gemini",
|
|
515
602
|
configPath: () => join(home(), ".gemini", "settings.json"),
|
|
603
|
+
projectSkillDir: ".agents/skills",
|
|
516
604
|
args: (entry) => [
|
|
517
605
|
"mcp", "add",
|
|
518
606
|
"-s", "user",
|
|
@@ -524,8 +612,16 @@ const gemini = addCommandHarness({
|
|
|
524
612
|
// --- the registry -----------------------------------------------------------
|
|
525
613
|
export const HARNESSES = [
|
|
526
614
|
claudeCode,
|
|
527
|
-
tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"
|
|
528
|
-
|
|
615
|
+
tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex", {
|
|
616
|
+
home: "CODEX_HOME",
|
|
617
|
+
dir: ".codex",
|
|
618
|
+
project: ".agents/skills",
|
|
619
|
+
}),
|
|
620
|
+
tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok", {
|
|
621
|
+
home: "GROK_HOME",
|
|
622
|
+
dir: ".grok",
|
|
623
|
+
project: ".grok/skills",
|
|
624
|
+
}),
|
|
529
625
|
cursor,
|
|
530
626
|
opencode,
|
|
531
627
|
gemini,
|
package/dist/lib/keystore.js
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { gitCommonDir, realPath } from "./gitRepo.js";
|
|
5
6
|
/** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
|
|
6
7
|
function keystoreDir() {
|
|
7
8
|
return process.env.RETASC_DIR || join(homedir(), ".retasc");
|
|
@@ -119,12 +120,74 @@ export function findBindingByPath(dir) {
|
|
|
119
120
|
// Checked AFTER the lookup so the repo root itself, the folder `bind` actually
|
|
120
121
|
// writes, is always eligible.
|
|
121
122
|
if (existsSync(join(cur, ".git")))
|
|
122
|
-
|
|
123
|
+
break;
|
|
123
124
|
const up = dirname(cur);
|
|
124
125
|
if (up === cur)
|
|
125
126
|
return undefined;
|
|
126
127
|
cur = up;
|
|
127
128
|
}
|
|
129
|
+
// RTSC-862 — the walk stopped at a repository root that is not itself bound. Before
|
|
130
|
+
// giving up, ask the question a person actually means: is this a WORKTREE of a repo
|
|
131
|
+
// they already bound?
|
|
132
|
+
//
|
|
133
|
+
// The stop above is correct and stays. An unbounded walk would let `retasc bind` in
|
|
134
|
+
// `~` silently bind every project beneath it, which is the cross-org leak RTSC-91
|
|
135
|
+
// exists to prevent. But `.git` is a FILE at the root of every git worktree, so that
|
|
136
|
+
// same stop fires on the first step inside one — and this repo's own workflow mandates
|
|
137
|
+
// a worktree per claimed issue, created as a SIBLING of the checkout. Every one of them
|
|
138
|
+
// resolved to nothing, with no error and no hint: the tools were simply absent. The
|
|
139
|
+
// rational response is to paste a raw key into a config that works everywhere, and that
|
|
140
|
+
// silently costs the watchdog, session rows, transcripts and the folder name.
|
|
141
|
+
//
|
|
142
|
+
// "Same repository" is decided by git's COMMON DIR, the one directory every worktree of
|
|
143
|
+
// a repo shares, so a sibling repo checked out inside a bound directory still resolves
|
|
144
|
+
// to nothing — different common dir. RTSC-91's property is untouched.
|
|
145
|
+
//
|
|
146
|
+
// A path match always wins, because this runs only after the walk found none: a
|
|
147
|
+
// worktree that is ITSELF bound keeps its own binding.
|
|
148
|
+
const here = gitCommonDir(cur);
|
|
149
|
+
if (!here)
|
|
150
|
+
return undefined;
|
|
151
|
+
// Memoized because this probes OTHER bindings' folders, not just our own, and it runs
|
|
152
|
+
// at every proxy start in every folder a harness opens. One `boundPath` on a stale
|
|
153
|
+
// network mount would otherwise be stat'd repeatedly while it blocks on the mount
|
|
154
|
+
// timeout. Two bindings in one repo is the common case, so the cache earns its keep.
|
|
155
|
+
const repoOf = new Map();
|
|
156
|
+
const commonDirOf = (p) => {
|
|
157
|
+
if (!repoOf.has(p))
|
|
158
|
+
repoOf.set(p, gitCommonDir(p));
|
|
159
|
+
return repoOf.get(p);
|
|
160
|
+
};
|
|
161
|
+
let best;
|
|
162
|
+
for (const hit of byPath.values()) {
|
|
163
|
+
// Cheap rejection first: a binding that cannot beat the current best on recency
|
|
164
|
+
// never needs its folder touched at all.
|
|
165
|
+
if (best && (hit.entry.createdAt ?? 0) <= (best.entry.createdAt ?? 0))
|
|
166
|
+
continue;
|
|
167
|
+
// Realpath'd, because `here` is: comparing a resolved path against an unresolved
|
|
168
|
+
// one silently never matches on macOS (`/var` vs `/private/var`).
|
|
169
|
+
const bound = realPath(hit.entry.boundPath);
|
|
170
|
+
// Same repository, the ordinary case: the bound folder is a worktree (or the main
|
|
171
|
+
// checkout) of this one.
|
|
172
|
+
let match = commonDirOf(bound) === here;
|
|
173
|
+
// Or the bound folder is the one that DIRECTLY CONTAINS this repository's git dir.
|
|
174
|
+
// That is the bare layout — `~/proj/.bare` beside `~/proj/main` — where the folder a
|
|
175
|
+
// person opens and binds, `~/proj`, is not itself a git repo and so has no common
|
|
176
|
+
// dir of its own. Without this, binding the folder they actually look at does
|
|
177
|
+
// nothing and they get the same silent no-tools symptom this issue is about.
|
|
178
|
+
//
|
|
179
|
+
// DIRECT parent, never an ancestor. `dirname("~/proj/.bare") === "~/proj"` matches,
|
|
180
|
+
// while binding `~` and hoping to catch `~/proj/.git` does not — which is the whole
|
|
181
|
+
// point, because that is RTSC-91's cross-org leak.
|
|
182
|
+
if (!match)
|
|
183
|
+
match = dirname(here) === bound;
|
|
184
|
+
if (!match)
|
|
185
|
+
continue;
|
|
186
|
+
// Newest wins, the tie-break `byPath` already uses: a folder bound, unbound and
|
|
187
|
+
// bound again leaves an older entry behind, and the fresher one is live.
|
|
188
|
+
best = hit;
|
|
189
|
+
}
|
|
190
|
+
return best;
|
|
128
191
|
}
|
|
129
192
|
/**
|
|
130
193
|
* RTSC-98: the ONE place that resolves a workspace's key + MCP url. Shared by the
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// RTSC-855: is the Retasc Agent Skill on this machine?
|
|
2
|
+
//
|
|
3
|
+
// This check can only live here. The server sees a bearer token and nothing else, no
|
|
4
|
+
// filesystem and no working directory, so it cannot know whether `skills/retasc/SKILL.md`
|
|
5
|
+
// was ever installed and must not pretend to infer it. The CLI runs on the machine, and
|
|
6
|
+
// `doctor` and `setup` already report local facts, so they are the honest place to ask.
|
|
7
|
+
//
|
|
8
|
+
// What is at stake is not cosmetic. The MCP tools give an agent the ability to claim
|
|
9
|
+
// work; the skill is what tells it what a lease is, that a claim needs its own worktree,
|
|
10
|
+
// what a checkpoint is for, and how a handoff into review works. An agent with the tools
|
|
11
|
+
// and not the manual looks like it is working and quietly gets the protocol wrong.
|
|
12
|
+
//
|
|
13
|
+
// Absence is a FINDING, not a failure. Nothing here touches an exit code, and nothing
|
|
14
|
+
// here writes: installing the skill is a decision for the human, made with one command
|
|
15
|
+
// they can read before they run it.
|
|
16
|
+
//
|
|
17
|
+
// Explicitly NOT in scope: deciding whether an installed copy is STALE. That needs a
|
|
18
|
+
// version stamp on the published skill, which does not exist yet, and a guess at it
|
|
19
|
+
// would produce the one output worse than silence, a confident wrong answer about a
|
|
20
|
+
// file the human can see is right there.
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { clean } from "./text.js";
|
|
24
|
+
/** The skill's folder name, and the name it is published under. */
|
|
25
|
+
export const SKILL_NAME = "retasc";
|
|
26
|
+
/**
|
|
27
|
+
* The one command to print.
|
|
28
|
+
*
|
|
29
|
+
* Harness-agnostic on purpose: `npx skills add Retasc/skills` is the first of the three
|
|
30
|
+
* routes our public README lists and the only one that is correct for all six harnesses
|
|
31
|
+
* at once, so a machine with Codex and Cursor on it gets a single line rather than a
|
|
32
|
+
* decision tree. The Claude Code plugin route and the git-clone route stay in the README
|
|
33
|
+
* where somebody choosing between them is already reading.
|
|
34
|
+
*
|
|
35
|
+
* `-g` is load-bearing and was missing here first time round. The installer defaults to
|
|
36
|
+
* PROJECT scope (`installGlobally = options.global ?? false`, and its interactive prompt
|
|
37
|
+
* offers Project first), which installs into the current folder. Without the flag this
|
|
38
|
+
* command would install the skill somewhere real, the agent would load it in that one
|
|
39
|
+
* folder, and every other folder would keep being told to run the same command again.
|
|
40
|
+
* Global is what "install this on my machine" means, and it is what a nudge printed by a
|
|
41
|
+
* machine-wide check should hand you.
|
|
42
|
+
*/
|
|
43
|
+
export const SKILL_INSTALL_CMD = "npx skills add Retasc/skills -g";
|
|
44
|
+
/** A directory is "holding the skill" when the skill's own file is readable in it. A
|
|
45
|
+
* bare `retasc/` folder is not an installed skill, and `existsSync` follows symlinks,
|
|
46
|
+
* which is how a checkout linked into `~/.claude/skills` counts (see skills/README.md). */
|
|
47
|
+
function holdsSkill(dir) {
|
|
48
|
+
try {
|
|
49
|
+
return existsSync(join(dir, SKILL_NAME, "SKILL.md"));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Which of these harnesses can read the skill, and which cannot.
|
|
57
|
+
*
|
|
58
|
+
* Takes the DETECTED harnesses rather than detecting for itself, so the answer is about
|
|
59
|
+
* agents that actually run here: telling someone the skill is missing from Gemini CLI on
|
|
60
|
+
* a machine with no Gemini CLI is noise dressed as a finding, and both callers have the
|
|
61
|
+
* detected list in hand already.
|
|
62
|
+
*/
|
|
63
|
+
export function skillReport(harnesses) {
|
|
64
|
+
const report = { installed: [], missing: [] };
|
|
65
|
+
for (const h of harnesses) {
|
|
66
|
+
let dirs = [];
|
|
67
|
+
try {
|
|
68
|
+
dirs = h.skillDirs();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
dirs = [];
|
|
72
|
+
}
|
|
73
|
+
// No known skills directory means we cannot answer for this harness, and the honest
|
|
74
|
+
// move is to say nothing rather than to invent a path and report a miss against it.
|
|
75
|
+
if (!dirs.length)
|
|
76
|
+
continue;
|
|
77
|
+
const entry = { label: h.label };
|
|
78
|
+
(dirs.some(holdsSkill) ? report.installed : report.missing).push(entry);
|
|
79
|
+
}
|
|
80
|
+
return report;
|
|
81
|
+
}
|
|
82
|
+
const names = (list) => clean(list.map((p) => p.label).join(", "));
|
|
83
|
+
/**
|
|
84
|
+
* The lines to print, prefixes included, shared verbatim by `doctor` and `setup`.
|
|
85
|
+
*
|
|
86
|
+
* One renderer because the issue asks both surfaces to say the same thing, and two
|
|
87
|
+
* copies of a sentence are two sentences the moment one of them is edited.
|
|
88
|
+
*
|
|
89
|
+
* `ℹ` and not `✗`: this is context about the machine, not a fault in the folder being
|
|
90
|
+
* checked, and it sits in a report whose ✓/✗ marks people read as pass and fail.
|
|
91
|
+
*/
|
|
92
|
+
export function skillLines(report) {
|
|
93
|
+
// One harness with the skill is enough to say so and stop talking. The nudge exists for
|
|
94
|
+
// the machine that has the manual NOWHERE; on a developer laptop that has Cursor and
|
|
95
|
+
// Gemini installed but points neither at Retasc, listing them as missing on every run
|
|
96
|
+
// is an unresolvable nag about work nobody intends to do, and a check people learn to
|
|
97
|
+
// scroll past has stopped being a check. Which harnesses DO have it is named in the ✓
|
|
98
|
+
// line, so nothing true is being hidden.
|
|
99
|
+
if (report.installed.length) {
|
|
100
|
+
return [` ✓ the Retasc Agent Skill is installed (${names(report.installed)}).`];
|
|
101
|
+
}
|
|
102
|
+
if (!report.missing.length)
|
|
103
|
+
return [];
|
|
104
|
+
return [
|
|
105
|
+
` ℹ the Retasc Agent Skill is not installed for ${names(report.missing)}.\n` +
|
|
106
|
+
` It is what teaches an agent to work the queue: dispatch, leases, checkpoints,\n` +
|
|
107
|
+
` handing off through review. Without it an agent has the tools and not the manual.\n` +
|
|
108
|
+
` Install: ${SKILL_INSTALL_CMD}`,
|
|
109
|
+
];
|
|
110
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|