@retasc/cli 1.9.0 → 1.10.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/dist/commands/identity.js +96 -0
- package/dist/commands/join.js +24 -6
- package/dist/index.js +21 -1
- package/package.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { api } from "../api.js";
|
|
2
|
+
import { clean } from "../lib/text.js";
|
|
3
|
+
import { isInteractive } from "./bind.js";
|
|
4
|
+
import { identityLoop } from "./join.js";
|
|
5
|
+
/**
|
|
6
|
+
* Which org's placeholders to offer.
|
|
7
|
+
*
|
|
8
|
+
* Same rule as `retasc billing`: explicit `--org-id` wins, one membership needs no
|
|
9
|
+
* question, several must be named. Deliberately NOT the folder binding — that resolves
|
|
10
|
+
* through an agent key, and this is a question only a signed-in human can answer, so
|
|
11
|
+
* reading identity off the folder would name an org this person might not even be in.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveOrg(orgs, orgId) {
|
|
14
|
+
// Org names are human-typed and land in a terminal that acts on escape sequences, the
|
|
15
|
+
// same rule the ghost rows follow. `clean` here rather than at each interpolation so a
|
|
16
|
+
// later line added to this list cannot forget it.
|
|
17
|
+
const list = () => orgs.map((o) => ` ${o.id} ${clean(o.name)}${o.slug ? ` (${clean(o.slug)})` : ""}`);
|
|
18
|
+
if (orgId) {
|
|
19
|
+
const found = orgs.find((o) => o.id === orgId);
|
|
20
|
+
// REFUSE an id we can't see, rather than passing it through for the server to reject.
|
|
21
|
+
// The server does not reject it: `memberOf` returns null for a non-member AND for a
|
|
22
|
+
// suspended one, and `claimableGhosts` maps null to `{asked: true}` — the exact shape a
|
|
23
|
+
// permanent decline returns. Passing through would make this command answer "you've
|
|
24
|
+
// already said none of these are you" to someone who simply typed the wrong id, which
|
|
25
|
+
// is a false statement about their own history and the one thing it must never say.
|
|
26
|
+
if (!found) {
|
|
27
|
+
throw new Error(`You're not an active member of ${clean(orgId)} — or that isn't an org id.` +
|
|
28
|
+
(orgs.length ? `\nOrgs you can ask about:\n${list().join("\n")}` : ""));
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
if (orgs.length === 0)
|
|
33
|
+
throw new Error("You're not a member of any org yet.");
|
|
34
|
+
if (orgs.length > 1) {
|
|
35
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list().join("\n")}`);
|
|
36
|
+
}
|
|
37
|
+
return orgs[0];
|
|
38
|
+
}
|
|
39
|
+
/** What to print once the loop is done. `join` prints nothing; this command must. */
|
|
40
|
+
function report(outcome, orgLabel) {
|
|
41
|
+
switch (outcome) {
|
|
42
|
+
case "claimed":
|
|
43
|
+
case "unavailable":
|
|
44
|
+
// Both already said their piece, line by line, as they happened.
|
|
45
|
+
return;
|
|
46
|
+
case "none":
|
|
47
|
+
// The common answer, and a genuinely good one — say it plainly rather than exiting
|
|
48
|
+
// silently, which reads like the command failed to run.
|
|
49
|
+
console.log(`Nothing waiting for you in ${orgLabel}.`);
|
|
50
|
+
console.log("Run this again after a migration — each import brings its own people across.");
|
|
51
|
+
return;
|
|
52
|
+
case "answered":
|
|
53
|
+
// The permanent decline. Worth its own wording: from in here it is indistinguishable
|
|
54
|
+
// from "none", and someone who declined during an earlier import and now has a real
|
|
55
|
+
// placeholder from a NEW one would otherwise read "nothing waiting" as the truth.
|
|
56
|
+
console.log(`You've already said none of the imported people in ${orgLabel} are you.`);
|
|
57
|
+
console.log("That answer covers the whole org, including later migrations.");
|
|
58
|
+
console.log("If a newer import did carry you across, an owner can link it from the Team page.");
|
|
59
|
+
return;
|
|
60
|
+
case "declined":
|
|
61
|
+
console.log("Noted — you won't be asked again.");
|
|
62
|
+
return;
|
|
63
|
+
case "left":
|
|
64
|
+
console.log("Nothing linked. Run `retasc identity` again whenever you want to look.");
|
|
65
|
+
return;
|
|
66
|
+
case "skipped":
|
|
67
|
+
// Only reachable non-interactively; the interactive guard below catches that first.
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function identityAction(opts, deps = {}) {
|
|
72
|
+
const d = {
|
|
73
|
+
me: () => api.me(),
|
|
74
|
+
// One implementation of the irreversible question, shared with `join`. Two surfaces
|
|
75
|
+
// asking it in two different voices is how someone ends up answering the one they trust
|
|
76
|
+
// less — the same reason its wording already mirrors the Dash notice.
|
|
77
|
+
loop: (orgId, orgLabel) => identityLoop(orgId, {}, orgLabel),
|
|
78
|
+
interactive: isInteractive,
|
|
79
|
+
...deps,
|
|
80
|
+
};
|
|
81
|
+
// Refuse up front rather than exiting 0 in silence. `identityLoop` returns "skipped"
|
|
82
|
+
// without a TTY — correct inside `join`, where the folder setup is the point and the
|
|
83
|
+
// question is optional, and wrong here, where the question IS the command.
|
|
84
|
+
if (!d.interactive()) {
|
|
85
|
+
throw new Error("`retasc identity` needs an interactive terminal — linking an imported person is irreversible, so it always asks first.");
|
|
86
|
+
}
|
|
87
|
+
const me = await d.me();
|
|
88
|
+
const org = resolveOrg(me.orgs, opts.orgId);
|
|
89
|
+
// The NAME alone, not "name (slug)". It lands mid-sentence in both surfaces — "When Acme
|
|
90
|
+
// migrated…" and "Nothing waiting for you in Acme." The slug only earns its place in the
|
|
91
|
+
// several-orgs error, where it is there to be copied.
|
|
92
|
+
const orgLabel = clean(org.name);
|
|
93
|
+
const outcome = await d.loop(org.id, orgLabel);
|
|
94
|
+
report(outcome, orgLabel);
|
|
95
|
+
return outcome;
|
|
96
|
+
}
|
package/dist/commands/join.js
CHANGED
|
@@ -53,19 +53,28 @@ deps = {}) {
|
|
|
53
53
|
// authorship AND their dispatch lane onto your account, irreversibly, and there is no
|
|
54
54
|
// CLI path back. A scripted run has nobody to be wrong on behalf of.
|
|
55
55
|
if (!d.interactive() || opts.yes)
|
|
56
|
-
return;
|
|
56
|
+
return "skipped";
|
|
57
|
+
// Whether anything was linked BEFORE the round that ends the loop. A claim is followed by
|
|
58
|
+
// another round (a second migrated tool may still be offerable), and that round normally
|
|
59
|
+
// ends in "nothing left" — which must not erase the claim that just happened.
|
|
60
|
+
let claimedAny = false;
|
|
57
61
|
for (let round = 0; round < MAX_IDENTITY_ROUNDS; round++) {
|
|
58
62
|
let ghosts;
|
|
59
63
|
try {
|
|
60
64
|
const res = await d.claimableGhosts({ orgId });
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
// Two different endings the server returns on one shape. "Answered" is this human's
|
|
66
|
+
// permanent org-wide decline; "none" is simply an empty list, and they can be asked
|
|
67
|
+
// again after the next migration. `join` treats both as silence; `identity` does not.
|
|
68
|
+
if (res.asked)
|
|
69
|
+
return claimedAny ? "claimed" : "answered";
|
|
70
|
+
if (!res.ghosts.length)
|
|
71
|
+
return claimedAny ? "claimed" : "none";
|
|
63
72
|
ghosts = res.ghosts;
|
|
64
73
|
}
|
|
65
74
|
catch (e) {
|
|
66
75
|
const { message } = formatError(e);
|
|
67
76
|
console.error(` ! Couldn't check for imported history (${message}). Carrying on.`);
|
|
68
|
-
return;
|
|
77
|
+
return claimedAny ? "claimed" : "unavailable";
|
|
69
78
|
}
|
|
70
79
|
console.log(round === 0
|
|
71
80
|
? `\nWhen ${orgLabel ?? "this org"} migrated, some people were carried across.\n` +
|
|
@@ -87,8 +96,13 @@ deps = {}) {
|
|
|
87
96
|
}
|
|
88
97
|
catch (e) {
|
|
89
98
|
console.error(` ! ${formatError(e).message}`);
|
|
99
|
+
// NOT "declined". A caller answers that with "you won't be asked again", and the
|
|
100
|
+
// write is the only thing that makes it true — the flag never landed, so they WILL
|
|
101
|
+
// be asked again. The error is already on stderr; `unavailable` adds no second
|
|
102
|
+
// sentence on top of it rather than a reassuring false one.
|
|
103
|
+
return "unavailable";
|
|
90
104
|
}
|
|
91
|
-
return;
|
|
105
|
+
return "declined";
|
|
92
106
|
}
|
|
93
107
|
// Confirm before claiming — it is irreversible and it moves dispatch routing, so it
|
|
94
108
|
// must not be a single keystroke on a row in a list. Same qualitative wording as the
|
|
@@ -106,6 +120,7 @@ deps = {}) {
|
|
|
106
120
|
try {
|
|
107
121
|
const res = await d.claimGhost({ orgId, memberId: chosen.id });
|
|
108
122
|
console.log(`✓ Linked ${clean(res.name)}.`);
|
|
123
|
+
claimedAny = true;
|
|
109
124
|
}
|
|
110
125
|
catch (e) {
|
|
111
126
|
// SOURCE_ALREADY_CLAIMED, IMPORT_RUNNING, NOT_CLAIMABLE — all readable, all reachable
|
|
@@ -114,11 +129,14 @@ deps = {}) {
|
|
|
114
129
|
console.error(` ✗ ${code ? `${code}: ` : ""}${message}`);
|
|
115
130
|
if (hint)
|
|
116
131
|
console.error(` → ${hint}`);
|
|
117
|
-
return;
|
|
132
|
+
return claimedAny ? "claimed" : "unavailable";
|
|
118
133
|
}
|
|
119
134
|
// Claiming one ClickUp identity removes EVERY remaining ClickUp row (`claimableGhosts`
|
|
120
135
|
// filters by spent source), so the next round can only ever offer a different tool.
|
|
121
136
|
}
|
|
137
|
+
// Round budget spent. Only reachable by declining individual rows over and over — the
|
|
138
|
+
// list is unchanged each time, so nothing was linked and nothing was answered for good.
|
|
139
|
+
return claimedAny ? "claimed" : "left";
|
|
122
140
|
}
|
|
123
141
|
export async function joinAction(link, opts) {
|
|
124
142
|
// Client-side, before anything else: the code is what the server matches, and someone
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { installGate } from "./commands/gate.js";
|
|
|
7
7
|
import { claimAction } from "./commands/claim.js";
|
|
8
8
|
import { bindAction } from "./commands/bind.js";
|
|
9
9
|
import { joinAction } from "./commands/join.js";
|
|
10
|
+
import { identityAction } from "./commands/identity.js";
|
|
10
11
|
import { doctorAction } from "./commands/doctor.js";
|
|
11
12
|
import { billingAction } from "./commands/billing.js";
|
|
12
13
|
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
@@ -372,11 +373,30 @@ program
|
|
|
372
373
|
.option("--project-id <id>", "Which project to bind to (skips the picker)")
|
|
373
374
|
.option("--agent <name>", "Agent member name (default: auto)")
|
|
374
375
|
.option("--runtime <runtime>", "Agent runtime", "claude-code")
|
|
375
|
-
|
|
376
|
+
// RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
|
|
377
|
+
// it, so the flag that causes the gap is the right place to say how to close it.
|
|
378
|
+
.option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
|
|
376
379
|
.allowExcessArguments(false)
|
|
377
380
|
.action(async (link, opts) => {
|
|
378
381
|
await joinAction(link, opts).catch(fail);
|
|
379
382
|
});
|
|
383
|
+
// RTSC-477 — the same question `join` asks, on demand. `join` fires once, at the moment you
|
|
384
|
+
// accept an invite; placeholders arrive with EVERY migration, and claiming is per source, so
|
|
385
|
+
// the question recurs and needed a surface that recurs with it.
|
|
386
|
+
//
|
|
387
|
+
// No `-y/--yes`, and no `identity claim <name>` subcommand, on purpose: a claim is
|
|
388
|
+
// irreversible and moves dispatch routing, so it is never answered on a script's behalf.
|
|
389
|
+
// `allowExcessArguments(false)` therefore rejects `retasc identity claim …` outright rather
|
|
390
|
+
// than ignoring the words and prompting anyway.
|
|
391
|
+
program
|
|
392
|
+
.command("identity")
|
|
393
|
+
.description("Link imported history to your account: shows the people a migration carried into this org and asks which one is you.")
|
|
394
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
395
|
+
.allowExcessArguments(false)
|
|
396
|
+
.action(async (opts) => {
|
|
397
|
+
requireLogin();
|
|
398
|
+
await identityAction({ orgId: opts.orgId }).catch(fail);
|
|
399
|
+
});
|
|
380
400
|
// --- mcp wiring ------------------------------------------------------------
|
|
381
401
|
const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
|
|
382
402
|
mcp
|
package/package.json
CHANGED