@shardflux/cli 0.14.5 → 0.15.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.
- package/CHANGELOG.md +69 -0
- package/README.md +76 -12
- package/dist/account-args.js +31 -10
- package/dist/account-commands.d.ts +2 -0
- package/dist/account-commands.js +158 -14
- package/dist/acp-catalog.js +2 -2
- package/dist/acp-login.d.ts +20 -0
- package/dist/acp-login.js +122 -0
- package/dist/acp.js +36 -13
- package/dist/agent-commands.js +71 -6
- package/dist/agent-git-commands.js +2 -1
- package/dist/agent-reachability.js +1 -1
- package/dist/agent-target-commands.d.ts +14 -0
- package/dist/agent-target-commands.js +79 -13
- package/dist/agent-targets.d.ts +7 -0
- package/dist/agent-targets.js +44 -32
- package/dist/args.d.ts +3 -1
- package/dist/args.js +21 -9
- package/dist/browser-login.d.ts +35 -0
- package/dist/browser-login.js +145 -0
- package/dist/codex-cloud.d.ts +49 -0
- package/dist/codex-cloud.js +157 -0
- package/dist/command-center-actions.js +7 -0
- package/dist/command-center-renderer.d.ts +3 -0
- package/dist/command-center-renderer.js +42 -38
- package/dist/command-center.js +65 -11
- package/dist/conversation-terminal.js +19 -0
- package/dist/conversations.d.ts +1 -1
- package/dist/conversations.js +21 -8
- package/dist/credential-prompts.d.ts +4 -2
- package/dist/credential-prompts.js +38 -16
- package/dist/device-bridge.d.ts +13 -0
- package/dist/device-bridge.js +14 -4
- package/dist/device-helper.js +2 -1
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +15 -2
- package/dist/format.js +3 -1
- package/dist/handlers.js +2 -1
- package/dist/initial-prompt.d.ts +26 -0
- package/dist/initial-prompt.js +196 -0
- package/dist/legal.d.ts +36 -0
- package/dist/legal.js +44 -0
- package/dist/main.js +6 -2
- package/dist/menubar.d.ts +8 -0
- package/dist/menubar.js +9 -1
- package/dist/onboarding-app/AGENTS.md +19 -0
- package/dist/onboarding-app/CLAUDE.md +19 -0
- package/dist/onboarding-app/README.md +12 -0
- package/dist/onboarding-app/dev.mjs +18 -0
- package/dist/onboarding-app/index.html +25 -0
- package/dist/onboarding-app/package.json +12 -0
- package/dist/onboarding-app/server.mjs +41 -0
- package/dist/onboarding-sample.d.ts +3 -0
- package/dist/onboarding-sample.js +29 -0
- package/dist/onboarding.d.ts +258 -0
- package/dist/onboarding.js +371 -0
- package/dist/terminal-brand.d.ts +5 -0
- package/dist/terminal-brand.js +18 -0
- package/package.json +3 -3
package/dist/account-commands.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { prepareOnboardingSample } from "./onboarding-sample.js";
|
|
2
|
+
import { onboardingReport, onboardingText } from "./onboarding.js";
|
|
1
3
|
/**
|
|
2
4
|
* The account plane (0.5.0; docs/progress/account-cli.md §5): everything a person does in the web app, from a
|
|
3
5
|
* terminal. Sign-in and the account calls go through the SDK's ShardfluxAccount with the user session (ctx.account());
|
|
@@ -13,16 +15,27 @@
|
|
|
13
15
|
* setup), else the only one there is; with several the usage error lists them.
|
|
14
16
|
*/
|
|
15
17
|
import { chmod, writeFile } from 'node:fs/promises';
|
|
18
|
+
import { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
|
|
16
19
|
import { API_KEY_TOOL_PERMISSIONS, CheckoutTimeoutError, Shardflux, ShardfluxAccount, ShardfluxApiError, codexIdentityProof, fetchBillingCatalog, parseEmailToken } from '@shardflux/sdk';
|
|
17
20
|
import { CLI_VERSION, UUID, parseDuration, parsePositiveInt } from "./args.js";
|
|
18
21
|
import { openUrl } from "./browser.js";
|
|
22
|
+
import { defaultClientName, startDeviceAuthorization, waitForDeviceSession } from "./browser-login.js";
|
|
23
|
+
import { legalFields, requireTermsAcceptance, showLegalNotice } from "./legal.js";
|
|
19
24
|
import { fileExists, oneOf, out, parseSlugVersion, principal, readInput, resolveWorkspace, str, strs, waitTimeout } from "./commands.js";
|
|
20
|
-
import { AuthConfigError, CheckoutEndedError, InterruptedError, MfaPendingError, NotFoundError, UsageError } from "./errors.js";
|
|
25
|
+
import { AuthConfigError, CheckoutEndedError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError } from "./errors.js";
|
|
21
26
|
import { apiKeyTable, attachmentTable, auditTable, authSessionTable, billingStatusText, dataExportText, egressVersionTable, invitationTable, invoiceTable, isGrantPlan, kv, memberTable, money, orgTable, overagePolicyText, planTable, projectEgressText, projectTable, table, time, toolsText, volumeDetail, volumeTable, workspaceEgressText, WORKSPACE_HEADERS, workspaceRow, } from "./format.js";
|
|
22
27
|
export const DEFAULT_API_URL = 'https://api.shardflux.dev';
|
|
23
28
|
const TOTP = /^\d{6}$/;
|
|
24
29
|
const ROLES = ['owner', 'admin', 'member', 'billing'];
|
|
25
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Every tool permission a key can carry, `computer` included (0.15.1+): the SDK's list before 0.21.1 left it out, so a
|
|
32
|
+
* key made by `shard setup` could not watch or use its workspace's desktop (computer stream 403).
|
|
33
|
+
*/
|
|
34
|
+
const TOOL_NAMES = [...new Set([...API_KEY_TOOL_PERMISSIONS, 'computer'])];
|
|
35
|
+
/** What `shard setup` gave a key before 0.15.1: every tool but `computer`. Such a saved key is replaced with a full one. */
|
|
36
|
+
export function outdatedDefaultKey(permissions) {
|
|
37
|
+
return !permissions.includes('computer') && TOOL_NAMES.every((t) => t === 'computer' || permissions.includes(t));
|
|
38
|
+
}
|
|
26
39
|
const DEFAULT_CHECKOUT_WAIT_MS = 15 * 60_000;
|
|
27
40
|
const CHECKOUT_POLL_MS = 2_000;
|
|
28
41
|
const NEXT_WORKSPACE = 'shard ws open demo --template python-node-browser';
|
|
@@ -283,7 +296,7 @@ async function signedInReport(ctx, account, user, session, extra = {}) {
|
|
|
283
296
|
['organizations', memberships === null ? '-' : memberships.length === 0 ? 'none yet' : memberships.map((m) => `${m.organization_name} (${m.organization_slug}, ${m.role})`).join(', ')],
|
|
284
297
|
]) +
|
|
285
298
|
`next: ${next}\n`;
|
|
286
|
-
out(ctx, human, { status: 'authenticated', user, session: { expires_at: session.expires_at, credentials_file: ctx.store.file }, organizations: memberships, next, ...extra });
|
|
299
|
+
out(ctx, human, { status: 'authenticated', user, session: { expires_at: session.expires_at, credentials_file: ctx.store.file }, organizations: memberships, next, ...legalFields(), ...extra });
|
|
287
300
|
return 0;
|
|
288
301
|
}
|
|
289
302
|
/** Best effort: ends a replaced session server side so it does not linger for 30 days. */
|
|
@@ -302,7 +315,7 @@ async function checkKey(ctx, token) {
|
|
|
302
315
|
const cloud = new Shardflux({ ...ctx.accountOptions(), apiKey: token });
|
|
303
316
|
const me = await cloud.me();
|
|
304
317
|
if (me.type === 'api_key' && me.api_key)
|
|
305
|
-
return { ok: true, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id };
|
|
318
|
+
return { ok: true, organizationId: me.api_key.organization_id, projectId: me.api_key.project_id, toolPermissions: me.api_key.tool_permissions ?? [] };
|
|
306
319
|
return { ok: false, reason: 'not a project API key' };
|
|
307
320
|
}
|
|
308
321
|
catch (err) {
|
|
@@ -617,7 +630,12 @@ async function runSetup(ctx, values, account) {
|
|
|
617
630
|
else
|
|
618
631
|
throw new UsageError(`${org.name} has ${projects.length} projects; pick one with --project <id|slug>:\n${choices(projects)}\n`, 'or save a default first: shard projects use <id|slug>');
|
|
619
632
|
}
|
|
620
|
-
|
|
633
|
+
// A key from an earlier `shard setup` (every tool but computer) is replaced unless --tool asks for a set: the desktop
|
|
634
|
+
// stream and screenshots need `computer`. The old key stays valid until revoked.
|
|
635
|
+
const outdated = keyCheck?.ok === true && strs(values.tool).length === 0 && values['read-only'] !== true && outdatedDefaultKey(keyCheck.toolPermissions);
|
|
636
|
+
if (outdated && !ctx.json)
|
|
637
|
+
ctx.io.stderr.write(`note: the saved API key ${profile.api_key.key_id} cannot use computer use; creating one with every tool.\n`);
|
|
638
|
+
const keep = keyCheck?.ok === true && profile.api_key?.project_id === project.id && !outdated;
|
|
621
639
|
let key;
|
|
622
640
|
let permissions = tools;
|
|
623
641
|
if (keep)
|
|
@@ -644,6 +662,16 @@ async function runSetup(ctx, values, account) {
|
|
|
644
662
|
permissions = k.tool_permissions;
|
|
645
663
|
}
|
|
646
664
|
const tag = (made) => (made ? 'created' : 'existing');
|
|
665
|
+
let codex;
|
|
666
|
+
const credentialHome = ctx.io.env.HOME ?? ctx.io.env.USERPROFILE;
|
|
667
|
+
if (values.codex !== false && permissions.includes('exec') && credentialHome && (await readAgentCredentials({ home: credentialHome, redact: value => ctx.addSecret(value) })).some(v => v.app === 'codex')) {
|
|
668
|
+
const cloud = new Shardflux({ apiKey: key.token, baseUrl: ctx.apiUrl });
|
|
669
|
+
await syncAgentCredentials(cloud, { home: credentialHome, redact: value => ctx.addSecret(value) });
|
|
670
|
+
await cloud.codex.enable({ signal: ctx.signal });
|
|
671
|
+
if (!ctx.json)
|
|
672
|
+
ctx.io.stderr.write('Starting your Codex remote device…\n');
|
|
673
|
+
codex = { ...await cloud.codex.waitConnector({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal }) };
|
|
674
|
+
}
|
|
647
675
|
const envNote = ctx.io.env.SHARDFLUX_API_KEY?.trim() ? 'note: SHARDFLUX_API_KEY is set in this environment and wins over the saved key; unset it to use the saved key.\n' : '';
|
|
648
676
|
return {
|
|
649
677
|
text: (keep ? 'Already set up; the saved API key works.\n' : 'Set up.\n') +
|
|
@@ -653,28 +681,112 @@ async function runSetup(ctx, values, account) {
|
|
|
653
681
|
['api key', `"${key.name}" ${key.key_id} (tools: ${toolsText(permissions)}) ${keep ? 'saved earlier' : 'created'}, saved in ${ctx.store.file}`],
|
|
654
682
|
]) +
|
|
655
683
|
envNote +
|
|
684
|
+
(codex ? 'Codex Cloud is ready. One account executor; no pairing. Its residency is billed as workspace usage.\n' : '') +
|
|
656
685
|
`next: ${NEXT_WORKSPACE}\n`,
|
|
657
686
|
data: {
|
|
658
687
|
organization: org,
|
|
659
688
|
project,
|
|
660
689
|
api_key: { id: key.id, key_id: key.key_id, name: key.name, project_id: key.project_id, tool_permissions: permissions },
|
|
661
690
|
created,
|
|
691
|
+
...(codex ? { codex } : {}),
|
|
662
692
|
credentials_file: ctx.store.file,
|
|
663
693
|
next: NEXT_WORKSPACE,
|
|
664
694
|
},
|
|
665
695
|
};
|
|
666
696
|
}
|
|
697
|
+
// ---- browser sign-in (0.15.0+) -----------------------------------------------------------------------------------
|
|
698
|
+
/** One NDJSON line of `auth login --browser --json` (the Mac app reads them). */
|
|
699
|
+
function event(ctx, value) {
|
|
700
|
+
ctx.io.stdout.write(`${JSON.stringify(value)}\n`);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* The browser sign-in (browser-login.ts): starts the request, opens or prints the page and the code, waits for the
|
|
704
|
+
* approval and saves the session exactly like `auth login` does. With --json the start is the `open` event; a failure
|
|
705
|
+
* becomes the `error` event (and is thrown on, for the exit code and stderr).
|
|
706
|
+
*/
|
|
707
|
+
async function browserSignIn(ctx, values, noticeShown = false) {
|
|
708
|
+
try {
|
|
709
|
+
const opts = ctx.accountOptions();
|
|
710
|
+
const clientName = str(values['client-name'])?.trim() || defaultClientName(ctx.hostname());
|
|
711
|
+
const d = await startDeviceAuthorization(opts, clientName, ctx.signal);
|
|
712
|
+
// The terms notice comes before the browser opens: a line here, the URLs in the event for an app to show. No
|
|
713
|
+
// --accept-terms: an account created in the browser agrees to the terms on the sign-up page, which records it.
|
|
714
|
+
if (ctx.json) {
|
|
715
|
+
event(ctx, { event: 'open', verification_uri_complete: d.verification_uri_complete, user_code: d.user_code, expires_in: d.expires_in, ...legalFields() });
|
|
716
|
+
}
|
|
717
|
+
else if (!noticeShown)
|
|
718
|
+
showLegalNotice(ctx);
|
|
719
|
+
const opened = values.open !== false && openUrl(d.verification_uri_complete, ctx.io.env);
|
|
720
|
+
const minutes = Math.max(1, Math.round(d.expires_in / 60));
|
|
721
|
+
if (!ctx.json) {
|
|
722
|
+
ctx.io.stderr.write(`${opened ? 'Opening your browser to sign in' : 'Open this page to sign in'}: ${d.verification_uri_complete}\n` +
|
|
723
|
+
`Your code: ${d.user_code} (approve only if the page shows the same code)\n` +
|
|
724
|
+
`Waiting for the approval in the browser (the code expires in ${minutes} minutes; Ctrl-C stops)...\n`);
|
|
725
|
+
}
|
|
726
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
727
|
+
const s = await waitForDeviceSession(opts, d, { signal: ctx.signal, sleep });
|
|
728
|
+
ctx.addSecret(s.session_token);
|
|
729
|
+
const replaced = ctx.store.profile(ctx.apiUrl).session?.token ?? null;
|
|
730
|
+
const saved = { token: s.session_token, expires_at: s.session_expires_at || null, user: { id: s.user.id, email: s.user.email }, state: 'active' };
|
|
731
|
+
const { switched } = ctx.saveSession(saved, s.user.email);
|
|
732
|
+
if (replaced && replaced !== saved.token)
|
|
733
|
+
await endQuietly(ctx, replaced);
|
|
734
|
+
if (switched && !ctx.json)
|
|
735
|
+
ctx.io.stderr.write(SWITCHED_NOTE);
|
|
736
|
+
const account = new ShardfluxAccount({ ...ctx.accountOptions(), sessionToken: s.session_token });
|
|
737
|
+
return { account, user: s.user, saved };
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
if (ctx.json) {
|
|
741
|
+
const info = describeError(err);
|
|
742
|
+
const interrupted = ctx.signal.aborted;
|
|
743
|
+
event(ctx, { event: 'error', code: interrupted ? 'interrupted' : info.code, message: interrupted ? 'Interrupted.' : info.message });
|
|
744
|
+
}
|
|
745
|
+
throw err;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/** `shard signup` on a terminal without a Codex login or --email (0.15.0+): the browser sign-in, then setup like signup. */
|
|
749
|
+
async function browserSignup(ctx, values) {
|
|
750
|
+
// signup has shown the notice (and asked) already.
|
|
751
|
+
const { account, user } = await browserSignIn(ctx, values, true);
|
|
752
|
+
let text = `Signed in as ${userLine(user)} in the browser.\nsession: saved in ${ctx.store.file}\n`;
|
|
753
|
+
const data = { status: 'authenticated', user, method: 'browser', credentials_file: ctx.store.file };
|
|
754
|
+
if (values.setup !== false) {
|
|
755
|
+
const setup = await runSetup(ctx, values, account);
|
|
756
|
+
text += setup.text;
|
|
757
|
+
data.setup = setup.data;
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
text += 'next: shard setup\n';
|
|
761
|
+
data.next = 'shard setup';
|
|
762
|
+
}
|
|
763
|
+
out(ctx, text, data);
|
|
764
|
+
return 0;
|
|
765
|
+
}
|
|
766
|
+
/** auth login: --browser, or on a terminal without --email/--codex. */
|
|
767
|
+
function wantsBrowser(ctx, values) {
|
|
768
|
+
const email = str(values.email) !== undefined;
|
|
769
|
+
if (values.browser === true) {
|
|
770
|
+
if (email || values.codex === true)
|
|
771
|
+
throw new UsageError('use one of --browser, --email or --codex');
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
if (values.browser === false || email || values.codex === true)
|
|
775
|
+
return false;
|
|
776
|
+
return ctx.input.interactive && !ctx.json;
|
|
777
|
+
}
|
|
667
778
|
// ---- handlers --------------------------------------------------------------------------------------------------
|
|
668
779
|
export const ACCOUNT_HANDLERS = {
|
|
669
780
|
async 'auth register'(ctx, values) {
|
|
670
781
|
const email = emailArg(need(values, 'email', '--email you@example.com'), '--email');
|
|
671
782
|
const name = str(values.name)?.trim();
|
|
783
|
+
const terms = await requireTermsAcceptance(ctx, values);
|
|
672
784
|
const password = await ctx.input.newPassword(`Choose a password for ${email}: `, 'The password', pipe(`shard auth register --email ${email}`));
|
|
673
|
-
const r = await ShardfluxAccount.register({ ...ctx.accountOptions(), email, password, ...(name ? { displayName: name } : {}) });
|
|
785
|
+
const r = await ShardfluxAccount.register({ ...ctx.accountOptions(), email, password, ...(name ? { displayName: name } : {}), ...terms });
|
|
674
786
|
const next = [`shard auth verify-email '<link from the email>'`, `${pipe(`shard auth login --email ${email}`)}`];
|
|
675
787
|
out(ctx, `Registration received for ${email}.\n` +
|
|
676
788
|
`Check that inbox for a verification link (valid 24 hours). If the address already has an account, no new one is created: sign in instead.\n` +
|
|
677
|
-
`next:\n 1. open the link, or: ${next[0]}\n 2. ${next[1]}\n`, { status: r.status, email, next_steps: next });
|
|
789
|
+
`next:\n 1. open the link, or: ${next[0]}\n 2. ${next[1]}\n`, { status: r.status, email, next_steps: next, ...legalFields() });
|
|
678
790
|
return 0;
|
|
679
791
|
},
|
|
680
792
|
async 'auth verify-email'(ctx, _values, [link]) {
|
|
@@ -711,10 +823,26 @@ export const ACCOUNT_HANDLERS = {
|
|
|
711
823
|
return 0;
|
|
712
824
|
},
|
|
713
825
|
async 'auth login'(ctx, values) {
|
|
826
|
+
if (wantsBrowser(ctx, values)) {
|
|
827
|
+
if (factorFlags(values) !== null)
|
|
828
|
+
throw new UsageError('--code and --recovery-code go with --email; in the browser the sign-in page asks for your second factor');
|
|
829
|
+
const { account, user, saved } = await browserSignIn(ctx, values);
|
|
830
|
+
if (ctx.json) {
|
|
831
|
+
event(ctx, { event: 'signed_in', email: user.email });
|
|
832
|
+
return 0;
|
|
833
|
+
}
|
|
834
|
+
return signedInReport(ctx, account, user, saved);
|
|
835
|
+
}
|
|
836
|
+
if (values.open !== undefined || str(values['client-name']) !== undefined)
|
|
837
|
+
throw new UsageError('--no-open and --client-name go with --browser', 'shard auth login --browser --no-open');
|
|
714
838
|
if (values.codex === true)
|
|
715
839
|
return ACCOUNT_HANDLERS.signup(ctx, { ...values, setup: false, codex: true, email: undefined }, []);
|
|
716
|
-
const
|
|
840
|
+
const given = str(values.email)?.trim();
|
|
841
|
+
if (!given)
|
|
842
|
+
throw new UsageError('--email is required to sign in with a password, e.g. --email you@example.com', 'or sign in with your browser: shard auth login --browser (or with your Codex login: shard auth login --codex)');
|
|
843
|
+
const email = emailArg(given, '--email');
|
|
717
844
|
let factor = factorFlags(values);
|
|
845
|
+
showLegalNotice(ctx);
|
|
718
846
|
const password = await ctx.input.secret(`Password for ${email}: `, 'The password', pipe(`shard auth login --email ${email}`));
|
|
719
847
|
let latest = null;
|
|
720
848
|
const { account, result } = await ShardfluxAccount.login({
|
|
@@ -971,8 +1099,18 @@ export const ACCOUNT_HANDLERS = {
|
|
|
971
1099
|
return 0;
|
|
972
1100
|
},
|
|
973
1101
|
async setup(ctx, values) {
|
|
1102
|
+
if (values['prepare-sample'] === true) {
|
|
1103
|
+
const path = await prepareOnboardingSample(ctx.store.dir);
|
|
1104
|
+
out(ctx, `Daylight is ready at ${path}\n`, { sample_path: path });
|
|
1105
|
+
return 0;
|
|
1106
|
+
}
|
|
1107
|
+
if (values.inspect === true || (ctx.json && ctx.sessionSource() === null)) {
|
|
1108
|
+
const onboarding = await onboardingReport(ctx, values);
|
|
1109
|
+
out(ctx, onboardingText(onboarding), { onboarding });
|
|
1110
|
+
return 0;
|
|
1111
|
+
}
|
|
974
1112
|
const r = await runSetup(ctx, values, ctx.account());
|
|
975
|
-
out(ctx, r.text, r.data);
|
|
1113
|
+
out(ctx, r.text, ctx.json ? { ...r.data, onboarding: await onboardingReport(ctx, values) } : r.data);
|
|
976
1114
|
return 0;
|
|
977
1115
|
},
|
|
978
1116
|
async signup(ctx, values) {
|
|
@@ -980,6 +1118,10 @@ export const ACCOUNT_HANDLERS = {
|
|
|
980
1118
|
const name = str(values.name)?.trim();
|
|
981
1119
|
let codexNote = null;
|
|
982
1120
|
let idToken = null;
|
|
1121
|
+
if (values.codex === false && email === undefined)
|
|
1122
|
+
throw new UsageError('--no-codex needs an email address.', 'shard signup --no-codex --email you@example.com');
|
|
1123
|
+
// Before the Codex login is read (its refresh is a network call) and before the API: the agreement to the terms.
|
|
1124
|
+
const terms = await requireTermsAcceptance(ctx, values);
|
|
983
1125
|
if (values.codex !== false) {
|
|
984
1126
|
const proof = await codexIdentityProof({ env: ctx.io.env, clientName: 'shard', clientVersion: CLI_VERSION });
|
|
985
1127
|
if (proof.ok) {
|
|
@@ -990,17 +1132,19 @@ export const ACCOUNT_HANDLERS = {
|
|
|
990
1132
|
}
|
|
991
1133
|
}
|
|
992
1134
|
else if (email === undefined) {
|
|
993
|
-
|
|
1135
|
+
if (!ctx.input.interactive || ctx.json)
|
|
1136
|
+
throw new UsageError(`No Codex login to sign up with: ${proof.message}`, 'shard signup --email you@example.com, or in the browser: shard auth login --browser');
|
|
1137
|
+
// On a terminal (0.15.0+): sign up or sign in on the web page instead, then set up as below.
|
|
1138
|
+
ctx.io.stderr.write(`No Codex login on this computer (${proof.message}); continuing in the browser.\n`);
|
|
1139
|
+
return browserSignup(ctx, values);
|
|
994
1140
|
}
|
|
995
1141
|
}
|
|
996
|
-
else if (email === undefined) {
|
|
997
|
-
throw new UsageError('--no-codex needs an email address.', 'shard signup --no-codex --email you@example.com');
|
|
998
|
-
}
|
|
999
1142
|
let latest = null;
|
|
1000
1143
|
const { account, result } = await ShardfluxAccount.signup({
|
|
1001
1144
|
...ctx.accountOptions(),
|
|
1002
1145
|
...(idToken !== null ? { codexIdToken: idToken } : { email: email }),
|
|
1003
1146
|
...(name ? { displayName: name } : {}),
|
|
1147
|
+
...terms,
|
|
1004
1148
|
onSessionToken: (s) => {
|
|
1005
1149
|
ctx.addSecret(s.token);
|
|
1006
1150
|
latest = s;
|
|
@@ -1040,7 +1184,7 @@ export const ACCOUNT_HANDLERS = {
|
|
|
1040
1184
|
? 'email not verified yet: workspaces run on the Free plan now; verify to start the trial: shard verify'
|
|
1041
1185
|
: 'email not verified yet: verify it to run workspaces: shard verify';
|
|
1042
1186
|
let text = `${result.created ? 'Signed up' : 'Signed in'} as ${userLine(user)} (${how}).\nsession: saved in ${ctx.store.file}\n`;
|
|
1043
|
-
const data = { status: 'authenticated', created: result.created, access, user, method: idToken !== null ? 'codex' : 'email', credentials_file: ctx.store.file };
|
|
1187
|
+
const data = { status: 'authenticated', created: result.created, access, user, method: idToken !== null ? 'codex' : 'email', credentials_file: ctx.store.file, ...legalFields() };
|
|
1044
1188
|
if (access !== 'verification_required' && values.setup !== false) {
|
|
1045
1189
|
const setup = await runSetup(ctx, values, account);
|
|
1046
1190
|
text += setup.text;
|
package/dist/acp-catalog.js
CHANGED
|
@@ -2,8 +2,8 @@ import { objectValue } from "./acp-rpc.js";
|
|
|
2
2
|
// T3's installation probe works offline, without a VM. These are harness
|
|
3
3
|
// labels only. Real sessions always replace them with guest metadata.
|
|
4
4
|
export const ACP_PROBE_CATALOG = {
|
|
5
|
-
claude: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: '
|
|
6
|
-
codex: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: '
|
|
5
|
+
claude: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: 'Default' }] } },
|
|
6
|
+
codex: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: 'Default' }] } },
|
|
7
7
|
};
|
|
8
8
|
export function nativeModelOptions(session) {
|
|
9
9
|
const configs = Array.isArray(session.configOptions) ? session.configOptions.map(objectValue) : [];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
/**
|
|
3
|
+
* The ACP terminal sign-in method. An editor (T3 Code) runs `shard acp --login` in its own terminal when the user
|
|
4
|
+
* chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
|
|
5
|
+
*/
|
|
6
|
+
export declare const ACP_LOGIN_METHOD: {
|
|
7
|
+
readonly id: "shardflux-login";
|
|
8
|
+
readonly name: "Sign in to Shardflux";
|
|
9
|
+
readonly description: "Create or sign in to your Shardflux account and connect Claude Code and Codex.";
|
|
10
|
+
readonly type: "terminal";
|
|
11
|
+
readonly args: readonly ["--login"];
|
|
12
|
+
};
|
|
13
|
+
/** Whether an API key is configured (no request; a revoked key surfaces as auth_required on its first call). */
|
|
14
|
+
export declare function acpSignedIn(ctx: Ctx): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
|
|
17
|
+
* account (signed up or in with this Mac's Codex login, else an email), its API key, the Codex login copy and the
|
|
18
|
+
* cloud Claude sign-in.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runAcpLogin(ctx: Ctx): Promise<number>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { describeError } from "./errors.js";
|
|
6
|
+
import { cloudClaudeLogin, loginClaudeInWorkspace } from "./claude-cloud-login.js";
|
|
7
|
+
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
8
|
+
/**
|
|
9
|
+
* The ACP terminal sign-in method. An editor (T3 Code) runs `shard acp --login` in its own terminal when the user
|
|
10
|
+
* chooses it, then reconnects; ACP appends these args to the configured `shard acp` invocation.
|
|
11
|
+
*/
|
|
12
|
+
export const ACP_LOGIN_METHOD = {
|
|
13
|
+
id: 'shardflux-login',
|
|
14
|
+
name: 'Sign in to Shardflux',
|
|
15
|
+
description: 'Create or sign in to your Shardflux account and connect Claude Code and Codex.',
|
|
16
|
+
type: 'terminal',
|
|
17
|
+
args: ['--login'],
|
|
18
|
+
};
|
|
19
|
+
/** Whether an API key is configured (no request; a revoked key surfaces as auth_required on its first call). */
|
|
20
|
+
export function acpSignedIn(ctx) {
|
|
21
|
+
try {
|
|
22
|
+
ctx.cloud();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// Only a missing or malformed key means signed out; anything else surfaces on the call that needs the API.
|
|
27
|
+
return describeError(error).code !== 'unauthenticated';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The sign-in flow checks the key against the API, so a revoked one is replaced. */
|
|
31
|
+
async function accountReady(ctx) {
|
|
32
|
+
if (!acpSignedIn(ctx))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
await ctx.cloud().agentCredentials.list();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (describeError(error).code === 'unauthenticated')
|
|
40
|
+
return false;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const say = (ctx, text) => { ctx.io.stderr.write(text); };
|
|
45
|
+
/**
|
|
46
|
+
* `shard acp --login`: everything a first conversation needs, each step skipped when it is already done: the
|
|
47
|
+
* account (signed up or in with this Mac's Codex login, else an email), its API key, the Codex login copy and the
|
|
48
|
+
* cloud Claude sign-in.
|
|
49
|
+
*/
|
|
50
|
+
export async function runAcpLogin(ctx) {
|
|
51
|
+
const { HANDLERS } = await import("./handlers.js");
|
|
52
|
+
const home = ctx.io.env.HOME ?? homedir();
|
|
53
|
+
const codex = existsSync(join(home, '.codex', 'auth.json'));
|
|
54
|
+
// The account commands' own reports (paths, next steps) are for the shard terminal, not an editor's sign-in panel.
|
|
55
|
+
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
56
|
+
// An editor's conversations need no Codex Cloud executor; setup here makes the account, project and key only.
|
|
57
|
+
const setup = () => HANDLERS.setup(quiet, { codex: false }, []);
|
|
58
|
+
say(ctx, 'Shardflux sign-in\n\n');
|
|
59
|
+
if (!await accountReady(ctx)) {
|
|
60
|
+
const session = ctx.store.profile(ctx.apiUrl).session;
|
|
61
|
+
if (session?.state === 'active') {
|
|
62
|
+
say(ctx, 'Creating your API key…\n');
|
|
63
|
+
await setup();
|
|
64
|
+
}
|
|
65
|
+
else if (codex) {
|
|
66
|
+
say(ctx, 'Signing in with the Codex login on this Mac…\n');
|
|
67
|
+
await HANDLERS.signup(quiet, { setup: false }, []);
|
|
68
|
+
await setup();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const email = (await ctx.input.answer('Email: '))?.trim();
|
|
72
|
+
if (!email) {
|
|
73
|
+
say(ctx, 'An email address is needed to create your account.\n');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await HANDLERS.signup(quiet, { email, codex: false, setup: false }, []);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// An existing account signs in with its password instead.
|
|
81
|
+
if (describeError(error).code !== 'conflict')
|
|
82
|
+
throw error;
|
|
83
|
+
await HANDLERS['auth login'](quiet, { email }, []);
|
|
84
|
+
}
|
|
85
|
+
await setup();
|
|
86
|
+
}
|
|
87
|
+
if (!await accountReady(ctx)) {
|
|
88
|
+
say(ctx, '\nThe account is not ready yet. Finish the step above, then choose Sign in again.\n');
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const user = ctx.store.profile(ctx.apiUrl).session?.user;
|
|
92
|
+
say(ctx, user?.email ? `Signed in as ${user.email}.\n` : 'Signed in.\n');
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
say(ctx, 'Already signed in.\n');
|
|
96
|
+
if (codex) {
|
|
97
|
+
await syncAgentCredentials(ctx.cloud(), { home, redact: (value) => ctx.addSecret(value) });
|
|
98
|
+
say(ctx, 'Codex: connected with the login on this Mac.\n');
|
|
99
|
+
}
|
|
100
|
+
if (await cloudClaudeLogin(ctx))
|
|
101
|
+
say(ctx, 'Claude Code: connected.\n');
|
|
102
|
+
else {
|
|
103
|
+
const answer = (await ctx.input.answer('\nConnect Claude Code now? It opens a Claude sign-in page. [Y/n] '))?.trim().toLowerCase() ?? 'n';
|
|
104
|
+
if (answer === '' || answer.startsWith('y')) {
|
|
105
|
+
// A session workspace exists only for this sign-in and is deleted with it.
|
|
106
|
+
const ws = await ctx.cloud().workspaces.open({ key: `shardflux-sign-in/${randomBytes(4).toString('hex')}`, template: 'coding', lifetime: 'session',
|
|
107
|
+
wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
|
|
108
|
+
try {
|
|
109
|
+
await loginClaudeInWorkspace(ctx, ws);
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await ws.close().catch(() => { });
|
|
113
|
+
}
|
|
114
|
+
say(ctx, 'Claude Code: connected.\n');
|
|
115
|
+
}
|
|
116
|
+
else if (!codex) {
|
|
117
|
+
say(ctx, 'Claude Code: not connected. Choose Sign in again to connect it.\n');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
say(ctx, '\nShardflux is ready. Return to your editor and start a conversation.\n');
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
package/dist/acp.js
CHANGED
|
@@ -12,6 +12,7 @@ import { describeError, ownHint } from "./errors.js";
|
|
|
12
12
|
import { serveAcpDeviceApprovals } from "./device-bridge.js";
|
|
13
13
|
import { syncAgentCredentials } from "./agent-credentials.js";
|
|
14
14
|
import { cloudClaudeLogin } from "./claude-cloud-login.js";
|
|
15
|
+
import { ACP_LOGIN_METHOD, acpSignedIn } from "./acp-login.js";
|
|
15
16
|
import { ensureMenubar, MenubarManager } from "./menubar.js";
|
|
16
17
|
const PREFIX = 'shardflux:';
|
|
17
18
|
export function acpModel(app, id) { return `${PREFIX}${app}:${id}`; }
|
|
@@ -23,21 +24,30 @@ export function parseAcpModel(value) {
|
|
|
23
24
|
throw new RpcError(-32602, 'Choose a model from Shardflux.');
|
|
24
25
|
return { app: match[1], id: match[2] };
|
|
25
26
|
}
|
|
27
|
+
/** An editor's first choice comes from the probe (`<app>:default`); every conversation keeps it valid as the app's own default model. */
|
|
28
|
+
const DEFAULT_MODEL = 'default';
|
|
29
|
+
const hasNativeDefault = (native) => nativeModelOptions(native).some(o => o.value === DEFAULT_MODEL);
|
|
26
30
|
export function acpModelOptions(catalog) {
|
|
27
31
|
return ['claude', 'codex'].flatMap(app => {
|
|
28
|
-
|
|
32
|
+
const native = nativeModelOptions(catalog[app] ?? {});
|
|
33
|
+
if (!native.length)
|
|
34
|
+
return [];
|
|
35
|
+
const options = hasNativeDefault(catalog[app] ?? {}) ? native : [{ value: DEFAULT_MODEL, name: 'Default' }, ...native];
|
|
36
|
+
return options.map((option) => {
|
|
29
37
|
return { ...option, value: acpModel(app, option.value), name: `${app === 'claude' ? 'Claude Code' : 'Codex'} · ${option.name}` };
|
|
30
38
|
});
|
|
31
39
|
});
|
|
32
40
|
}
|
|
41
|
+
const SIGN_IN = 'Sign in to Shardflux from your editor (or run shard acp --login).';
|
|
33
42
|
export function acpError(error) {
|
|
34
43
|
if (error instanceof RpcError) {
|
|
35
44
|
if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
|
|
36
|
-
return new RpcError(-32000, `${error.message}
|
|
45
|
+
return new RpcError(-32000, `${error.message.replace(/[.!]?$/, '.')} ${SIGN_IN}`, { code: 'unauthenticated', fix: SIGN_IN });
|
|
37
46
|
return error;
|
|
38
47
|
}
|
|
39
48
|
const info = describeError(error);
|
|
40
|
-
|
|
49
|
+
// ACP `auth_required` (-32000) with an unauthenticated code makes the editor offer the advertised sign-in method.
|
|
50
|
+
const fix = info.code === 'unauthenticated' ? SIGN_IN : info.code === 'not_found' || info.details?.reason === 'workspace_deleted' ? 'Open a new conversation.' : ownHint(error) ?? (info.code === 'quota_exceeded' || info.code === 'allowance_exhausted' ? 'Run shard billing plans.' : 'Try the message again.');
|
|
41
51
|
return new RpcError(-32000, `${info.message} ${fix}`, { code: info.code, fix, ...(info.details?.reason ? { reason: info.details.reason } : {}) });
|
|
42
52
|
}
|
|
43
53
|
/** Rewrite structural session/file references, never the agent's prose or command output. */
|
|
@@ -94,8 +104,9 @@ export class ShardfluxAcp {
|
|
|
94
104
|
if (params.protocolVersion !== 1 && params.protocolVersion !== 2)
|
|
95
105
|
throw new RpcError(-32602, 'Unsupported protocol version');
|
|
96
106
|
this.#initialized = true;
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
// T3 Code's model probe and its post-sign-in check open a session to test the provider; neither gets a VM.
|
|
108
|
+
this.#probe = ['t3-code-provider-test', 't3-code-provider-auth'].includes(String(objectValue(params.clientInfo).name));
|
|
109
|
+
return { protocolVersion: 1, agentInfo: { name: 'shardflux', title: 'Shardflux', version: CLI_VERSION }, authMethods: [ACP_LOGIN_METHOD],
|
|
99
110
|
agentCapabilities: { loadSession: true, promptCapabilities: { image: true, embeddedContext: true }, sessionCapabilities: { list: {}, resume: {}, close: {} } } };
|
|
100
111
|
}
|
|
101
112
|
if (!this.#initialized)
|
|
@@ -105,6 +116,9 @@ export class ShardfluxAcp {
|
|
|
105
116
|
return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
|
|
106
117
|
}
|
|
107
118
|
if (method === 'session/new') {
|
|
119
|
+
// Signed out, the editor gets ACP auth_required and offers the sign-in method, even on its model probe.
|
|
120
|
+
if (!acpSignedIn(this.#ctx))
|
|
121
|
+
throw new RpcError(-32000, 'Authentication required', { code: 'unauthenticated', fix: SIGN_IN });
|
|
108
122
|
if (this.#probe)
|
|
109
123
|
return this.#configuration(acpModel('claude', 'default'), { sessionId: 'shardflux-probe' }, ACP_PROBE_CATALOG);
|
|
110
124
|
if (typeof params.cwd !== 'string' || !isAbsolute(params.cwd))
|
|
@@ -163,12 +177,17 @@ export class ShardfluxAcp {
|
|
|
163
177
|
if (!acpModelOptions(record.catalog ?? {}).some(o => o.value === model))
|
|
164
178
|
throw new RpcError(-32602, 'This model is no longer available. Choose a model from this conversation.');
|
|
165
179
|
let result;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
180
|
+
// Our own default keeps the app's current model; there is nothing to tell the native session.
|
|
181
|
+
if (selected.id === DEFAULT_MODEL && !hasNativeDefault(record.catalog?.[selected.app] ?? {}))
|
|
182
|
+
result = objectValue(record.setup);
|
|
183
|
+
else {
|
|
184
|
+
try {
|
|
185
|
+
result = objectValue(await session.transport.rpc.request('session/set_config_option', { sessionId: record.nativeId, configId: 'model', value: selected.id }));
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
await this.#rejectModel(session, selected.id, e);
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
172
191
|
}
|
|
173
192
|
record.model = model;
|
|
174
193
|
this.#save(record);
|
|
@@ -327,7 +346,7 @@ export class ShardfluxAcp {
|
|
|
327
346
|
await session.transport?.close();
|
|
328
347
|
await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
|
|
329
348
|
if (app === 'claude' && !(await cloudClaudeLogin(ctx)))
|
|
330
|
-
throw new RpcError(-32000, '
|
|
349
|
+
throw new RpcError(-32000, 'Authentication required: connect Claude Code.', { code: 'unauthenticated', fix: SIGN_IN });
|
|
331
350
|
if (!record.conversationId) {
|
|
332
351
|
this.#prepare(session);
|
|
333
352
|
await session.prepared;
|
|
@@ -366,8 +385,12 @@ export class ShardfluxAcp {
|
|
|
366
385
|
throw new Error('ACP supervisor identity does not match this conversation.');
|
|
367
386
|
const owner = `${session.ws.id}:${status.pid}:${status.start}`;
|
|
368
387
|
const initialize = !record.nativeId || record.owner !== owner;
|
|
369
|
-
|
|
388
|
+
// A cold restore can replace the journal owner without changing the VM id.
|
|
389
|
+
// Its sequence starts at zero; an old cursor would drop initialize/load replies.
|
|
390
|
+
if (record.workspaceId !== session.ws.id || record.owner !== owner) {
|
|
370
391
|
record.from_seq = 0;
|
|
392
|
+
delete record.prompt_id;
|
|
393
|
+
}
|
|
371
394
|
record.workspaceId = session.ws.id;
|
|
372
395
|
const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
|
|
373
396
|
transport.rpc.handler = async (method, params, request) => {
|