@cruxy/cli 0.4.0 → 0.7.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/README.md +57 -16
- package/dist/agent/index.d.ts +0 -1
- package/dist/agent/index.js +0 -1
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.js +1 -1
- package/dist/agent/prompts.d.ts +2 -2
- package/dist/agent/prompts.js +9 -3
- package/dist/agent/session.d.ts +24 -0
- package/dist/agent/session.js +33 -7
- package/dist/approval/classify.d.ts +18 -0
- package/dist/approval/classify.js +162 -0
- package/dist/approval/index.d.ts +5 -0
- package/dist/approval/index.js +5 -0
- package/dist/approval/policy.d.ts +37 -0
- package/dist/approval/policy.js +81 -0
- package/dist/approval/prompt.d.ts +33 -0
- package/dist/approval/prompt.js +212 -0
- package/dist/approval/service.d.ts +36 -0
- package/dist/approval/service.js +37 -0
- package/dist/approval/types.d.ts +64 -0
- package/dist/approval/types.js +1 -0
- package/dist/cli/commands/init.d.ts +7 -0
- package/dist/cli/commands/init.js +40 -0
- package/dist/cli/commands/login.d.ts +8 -0
- package/dist/cli/commands/login.js +36 -0
- package/dist/cli/commands/pr.d.ts +8 -0
- package/dist/cli/commands/pr.js +87 -0
- package/dist/cli/commands/run.js +30 -52
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +54 -0
- package/dist/cli/program.js +21 -1
- package/dist/cli/repl.js +10 -1
- package/dist/cli/session-factory.d.ts +12 -0
- package/dist/cli/session-factory.js +88 -0
- package/dist/config/credentials.d.ts +10 -0
- package/dist/config/credentials.js +69 -0
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.js +1 -0
- package/dist/config/manager.d.ts +6 -1
- package/dist/config/manager.js +11 -1
- package/dist/config/schema.d.ts +48 -9
- package/dist/config/schema.js +15 -4
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors/constructors.d.ts +35 -0
- package/dist/errors/constructors.js +129 -2
- package/dist/errors/types.d.ts +8 -0
- package/dist/errors/types.js +17 -0
- package/dist/onboarding/detect.d.ts +26 -0
- package/dist/onboarding/detect.js +56 -0
- package/dist/onboarding/flow.d.ts +28 -0
- package/dist/onboarding/flow.js +100 -0
- package/dist/onboarding/index.d.ts +5 -0
- package/dist/onboarding/index.js +5 -0
- package/dist/onboarding/io.d.ts +8 -0
- package/dist/onboarding/io.js +133 -0
- package/dist/onboarding/steps.d.ts +17 -0
- package/dist/onboarding/steps.js +100 -0
- package/dist/onboarding/types.d.ts +81 -0
- package/dist/onboarding/types.js +6 -0
- package/dist/plan/approve.d.ts +16 -0
- package/dist/plan/approve.js +46 -0
- package/dist/plan/execute.d.ts +20 -0
- package/dist/plan/execute.js +31 -0
- package/dist/plan/index.d.ts +7 -0
- package/dist/plan/index.js +7 -0
- package/dist/plan/policy.d.ts +26 -0
- package/dist/plan/policy.js +45 -0
- package/dist/plan/render.d.ts +5 -0
- package/dist/plan/render.js +47 -0
- package/dist/plan/service.d.ts +39 -0
- package/dist/plan/service.js +118 -0
- package/dist/plan/submit-plan.d.ts +33 -0
- package/dist/plan/submit-plan.js +57 -0
- package/dist/plan/types.d.ts +60 -0
- package/dist/plan/types.js +6 -0
- package/dist/tools/create-pull-request.d.ts +24 -0
- package/dist/tools/create-pull-request.js +83 -0
- package/dist/tools/file/apply-patch.js +3 -3
- package/dist/tools/file/edit-file.js +6 -3
- package/dist/tools/file/write-file.js +6 -3
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/shell/run-command.js +11 -3
- package/dist/tools/types.d.ts +25 -6
- package/dist/vcs/auth.d.ts +22 -0
- package/dist/vcs/auth.js +29 -0
- package/dist/vcs/generate.d.ts +72 -0
- package/dist/vcs/generate.js +265 -0
- package/dist/vcs/git.d.ts +52 -0
- package/dist/vcs/git.js +152 -0
- package/dist/vcs/github.d.ts +44 -0
- package/dist/vcs/github.js +145 -0
- package/dist/vcs/guidance.d.ts +20 -0
- package/dist/vcs/guidance.js +76 -0
- package/dist/vcs/index.d.ts +7 -0
- package/dist/vcs/index.js +7 -0
- package/dist/vcs/service.d.ts +53 -0
- package/dist/vcs/service.js +79 -0
- package/dist/vcs/types.d.ts +57 -0
- package/dist/vcs/types.js +6 -0
- package/package.json +1 -1
- package/dist/agent/approval.d.ts +0 -41
- package/dist/agent/approval.js +0 -179
|
@@ -72,9 +72,10 @@ export function authMissingKey(provider, envVar) {
|
|
|
72
72
|
return new CruxyError({
|
|
73
73
|
code: ErrorCode.AuthMissingKey,
|
|
74
74
|
title: `no API key for provider "${provider}"`,
|
|
75
|
-
cause: `the ${envVar}
|
|
75
|
+
cause: `no key in the environment (${envVar}) or the credentials store`,
|
|
76
76
|
nextSteps: [
|
|
77
|
-
`
|
|
77
|
+
"run `cruxy login` to set a key interactively (saved to ~/.cruxy, owner-only)",
|
|
78
|
+
`or export ${envVar}=… in your shell (env always wins; never written to config)`,
|
|
78
79
|
],
|
|
79
80
|
meta: { provider, envVar },
|
|
80
81
|
});
|
|
@@ -208,6 +209,132 @@ export function indexFailed(underlying) {
|
|
|
208
209
|
underlying,
|
|
209
210
|
});
|
|
210
211
|
}
|
|
212
|
+
// ── approval (exit 10) ────────────────────────────────────────────────────────
|
|
213
|
+
/**
|
|
214
|
+
* A side-effecting action needs approval but cruxy can't ask (non-interactive,
|
|
215
|
+
* no policy). Default-deny — never auto-approve. A distinct exit code (10) so CI
|
|
216
|
+
* can tell "needed approval" apart from a usage error.
|
|
217
|
+
*/
|
|
218
|
+
export function approvalRequired(summary) {
|
|
219
|
+
return new CruxyError({
|
|
220
|
+
code: ErrorCode.ApprovalRequired,
|
|
221
|
+
title: "this action needs your approval, but cruxy is running non-interactively",
|
|
222
|
+
cause: `pending action — ${summary}`,
|
|
223
|
+
nextSteps: [
|
|
224
|
+
"run cruxy in an interactive terminal so you can approve actions",
|
|
225
|
+
"or scope the task to read-only actions (no file writes or shell commands)",
|
|
226
|
+
],
|
|
227
|
+
meta: { summary },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// ── vcs: forge + git (exit 4 / 2 / 6 / 5) ─────────────────────────────────────
|
|
231
|
+
/**
|
|
232
|
+
* No forge token could be resolved (PR generation, C.15). The chain is env →
|
|
233
|
+
* `gh auth token` → fail. We never prompt for, store, or persist a token, so the
|
|
234
|
+
* fix is always to provide one in the environment.
|
|
235
|
+
*/
|
|
236
|
+
export function forgeAuth(host = "github.com") {
|
|
237
|
+
return new CruxyError({
|
|
238
|
+
code: ErrorCode.ForgeAuth,
|
|
239
|
+
title: `no ${host} token available to open a pull request`,
|
|
240
|
+
cause: "checked GITHUB_TOKEN, GH_TOKEN, and `gh auth token` — none provided a token",
|
|
241
|
+
nextSteps: [
|
|
242
|
+
"export GITHUB_TOKEN=… (a personal access token with `repo` scope)",
|
|
243
|
+
"or install the GitHub CLI and run `gh auth login`",
|
|
244
|
+
],
|
|
245
|
+
meta: { host },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A commit/push was attempted on a protected branch (`main`/`master`/configured).
|
|
250
|
+
* The PR flow must branch off first; this is the last-line guard.
|
|
251
|
+
*/
|
|
252
|
+
export function gitProtectedBranch(branch) {
|
|
253
|
+
return new CruxyError({
|
|
254
|
+
code: ErrorCode.GitProtectedBranch,
|
|
255
|
+
title: `refusing to commit or push on the protected branch "${branch}"`,
|
|
256
|
+
cause: "cruxy never writes directly to a protected branch",
|
|
257
|
+
nextSteps: [
|
|
258
|
+
"switch to a feature branch first, e.g. `git switch -c feat/my-change`",
|
|
259
|
+
"adjust the protected list with `git.protectedBranches` in your config",
|
|
260
|
+
],
|
|
261
|
+
meta: { branch },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
/** The forge REST API returned an error (non-auth) while opening a PR. */
|
|
265
|
+
export function forgeApi(title, underlying, meta) {
|
|
266
|
+
return new CruxyError({
|
|
267
|
+
code: ErrorCode.ForgeApi,
|
|
268
|
+
title,
|
|
269
|
+
cause: messageOf(underlying),
|
|
270
|
+
nextSteps: [
|
|
271
|
+
"retry in a moment; if it persists, check the forge's status",
|
|
272
|
+
"re-run with --verbose to see the API response",
|
|
273
|
+
],
|
|
274
|
+
underlying,
|
|
275
|
+
meta,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* `git push` failed — most often the husky `pre-push` verify hook (build ·
|
|
280
|
+
* typecheck · lint · test) or a rejected non-fast-forward. We never `--force` or
|
|
281
|
+
* `--no-verify`, so the underlying reason is surfaced verbatim.
|
|
282
|
+
*/
|
|
283
|
+
export function gitPushFailed(branch, stderr) {
|
|
284
|
+
return new CruxyError({
|
|
285
|
+
code: ErrorCode.GitPushFailed,
|
|
286
|
+
title: `failed to push branch "${branch}"`,
|
|
287
|
+
cause: stderr?.trim() || undefined,
|
|
288
|
+
nextSteps: [
|
|
289
|
+
"check the error above (the pre-push hook runs build · typecheck · lint · test)",
|
|
290
|
+
"fix the reported issue and try again — cruxy never force-pushes or skips hooks",
|
|
291
|
+
],
|
|
292
|
+
meta: { branch },
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
// ── plan mode (exit 2 / 10) ───────────────────────────────────────────────────
|
|
296
|
+
/** The agent's proposed plan was missing or malformed (plan mode, C.31). */
|
|
297
|
+
export function planInvalid(reason) {
|
|
298
|
+
return new CruxyError({
|
|
299
|
+
code: ErrorCode.PlanInvalid,
|
|
300
|
+
title: "the agent did not produce a valid plan",
|
|
301
|
+
cause: reason,
|
|
302
|
+
nextSteps: [
|
|
303
|
+
"retry the task — the model must call `submit_plan` with at least one step",
|
|
304
|
+
"or run without `--plan` to execute directly",
|
|
305
|
+
],
|
|
306
|
+
meta: { reason },
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
/** The plan was rejected too many times without converging (plan mode, C.31). */
|
|
310
|
+
export function planRevisionLimit(limit) {
|
|
311
|
+
return new CruxyError({
|
|
312
|
+
code: ErrorCode.PlanRevisionLimit,
|
|
313
|
+
title: `plan not approved after ${limit} revision${limit === 1 ? "" : "s"}`,
|
|
314
|
+
cause: "the revision limit was reached without an approved plan",
|
|
315
|
+
nextSteps: [
|
|
316
|
+
"restate the task more concretely, or split it into smaller tasks",
|
|
317
|
+
"run without `--plan` to execute directly",
|
|
318
|
+
],
|
|
319
|
+
meta: { limit },
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Plan mode needs interactive approval but cruxy is running non-interactively.
|
|
324
|
+
* Default-deny — a plan is never auto-approved. Distinct code (exit 10) so CI can
|
|
325
|
+
* tell it apart from a per-action approval requirement.
|
|
326
|
+
*/
|
|
327
|
+
export function planApprovalRequired() {
|
|
328
|
+
return new CruxyError({
|
|
329
|
+
code: ErrorCode.PlanApprovalRequired,
|
|
330
|
+
title: "plan mode needs your approval, but cruxy is running non-interactively",
|
|
331
|
+
cause: "a plan can only be approved in an interactive terminal",
|
|
332
|
+
nextSteps: [
|
|
333
|
+
"run cruxy in an interactive terminal to review and approve the plan",
|
|
334
|
+
"or drop `--plan` (and `agent.planMode`) to execute directly",
|
|
335
|
+
],
|
|
336
|
+
});
|
|
337
|
+
}
|
|
211
338
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
212
339
|
export function internal(underlying) {
|
|
213
340
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -16,15 +16,21 @@ export declare const ErrorCode: {
|
|
|
16
16
|
readonly Usage: "CRUXY_E_USAGE";
|
|
17
17
|
readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
|
|
18
18
|
readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
|
|
19
|
+
readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
20
|
+
readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
|
|
21
|
+
readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
|
|
19
22
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
20
23
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
21
24
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
22
25
|
readonly AuthInvalid: "CRUXY_E_AUTH_INVALID";
|
|
26
|
+
readonly ForgeAuth: "CRUXY_E_FORGE_AUTH";
|
|
23
27
|
readonly GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE";
|
|
28
|
+
readonly GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED";
|
|
24
29
|
readonly Api: "CRUXY_E_API";
|
|
25
30
|
readonly ApiRateLimit: "CRUXY_E_API_RATE_LIMIT";
|
|
26
31
|
readonly ApiOverloaded: "CRUXY_E_API_OVERLOADED";
|
|
27
32
|
readonly BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED";
|
|
33
|
+
readonly ForgeApi: "CRUXY_E_FORGE_API";
|
|
28
34
|
readonly FileNotFound: "CRUXY_E_FILE_NOT_FOUND";
|
|
29
35
|
readonly PermissionDenied: "CRUXY_E_PERMISSION_DENIED";
|
|
30
36
|
readonly PathEscape: "CRUXY_E_PATH_ESCAPE";
|
|
@@ -33,6 +39,8 @@ export declare const ErrorCode: {
|
|
|
33
39
|
readonly IndexFailed: "CRUXY_E_INDEX_FAILED";
|
|
34
40
|
readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
|
|
35
41
|
readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
|
|
42
|
+
readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
|
|
43
|
+
readonly PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED";
|
|
36
44
|
};
|
|
37
45
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
38
46
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -18,19 +18,25 @@ export const ErrorCode = {
|
|
|
18
18
|
Usage: "CRUXY_E_USAGE",
|
|
19
19
|
ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
|
|
20
20
|
ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
|
|
21
|
+
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
22
|
+
PlanInvalid: "CRUXY_E_PLAN_INVALID",
|
|
23
|
+
PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
|
|
21
24
|
// config (exit 3)
|
|
22
25
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
23
26
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
24
27
|
// auth (exit 4)
|
|
25
28
|
AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY",
|
|
26
29
|
AuthInvalid: "CRUXY_E_AUTH_INVALID",
|
|
30
|
+
ForgeAuth: "CRUXY_E_FORGE_AUTH",
|
|
27
31
|
// network (exit 5)
|
|
28
32
|
GatewayUnreachable: "CRUXY_E_GATEWAY_UNREACHABLE",
|
|
33
|
+
GitPushFailed: "CRUXY_E_GIT_PUSH_FAILED",
|
|
29
34
|
// api (exit 6)
|
|
30
35
|
Api: "CRUXY_E_API",
|
|
31
36
|
ApiRateLimit: "CRUXY_E_API_RATE_LIMIT",
|
|
32
37
|
ApiOverloaded: "CRUXY_E_API_OVERLOADED",
|
|
33
38
|
BudgetExhausted: "CRUXY_E_BUDGET_EXHAUSTED",
|
|
39
|
+
ForgeApi: "CRUXY_E_FORGE_API",
|
|
34
40
|
// filesystem (exit 7)
|
|
35
41
|
FileNotFound: "CRUXY_E_FILE_NOT_FOUND",
|
|
36
42
|
PermissionDenied: "CRUXY_E_PERMISSION_DENIED",
|
|
@@ -42,6 +48,9 @@ export const ErrorCode = {
|
|
|
42
48
|
// skill (exit 9)
|
|
43
49
|
SkillInvalid: "CRUXY_E_SKILL_INVALID",
|
|
44
50
|
SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
|
|
51
|
+
// approval (exit 10)
|
|
52
|
+
ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
|
|
53
|
+
PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED",
|
|
45
54
|
};
|
|
46
55
|
/**
|
|
47
56
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -52,15 +61,21 @@ const EXIT_CODES = {
|
|
|
52
61
|
[ErrorCode.Usage]: 2,
|
|
53
62
|
[ErrorCode.ConfigKeyUnknown]: 2,
|
|
54
63
|
[ErrorCode.ProviderUnsupported]: 2,
|
|
64
|
+
[ErrorCode.GitProtectedBranch]: 2,
|
|
65
|
+
[ErrorCode.PlanInvalid]: 2,
|
|
66
|
+
[ErrorCode.PlanRevisionLimit]: 2,
|
|
55
67
|
[ErrorCode.ConfigParse]: 3,
|
|
56
68
|
[ErrorCode.ConfigInvalid]: 3,
|
|
57
69
|
[ErrorCode.AuthMissingKey]: 4,
|
|
58
70
|
[ErrorCode.AuthInvalid]: 4,
|
|
71
|
+
[ErrorCode.ForgeAuth]: 4,
|
|
59
72
|
[ErrorCode.GatewayUnreachable]: 5,
|
|
73
|
+
[ErrorCode.GitPushFailed]: 5,
|
|
60
74
|
[ErrorCode.Api]: 6,
|
|
61
75
|
[ErrorCode.ApiRateLimit]: 6,
|
|
62
76
|
[ErrorCode.ApiOverloaded]: 6,
|
|
63
77
|
[ErrorCode.BudgetExhausted]: 6,
|
|
78
|
+
[ErrorCode.ForgeApi]: 6,
|
|
64
79
|
[ErrorCode.FileNotFound]: 7,
|
|
65
80
|
[ErrorCode.PermissionDenied]: 7,
|
|
66
81
|
[ErrorCode.PathEscape]: 7,
|
|
@@ -69,6 +84,8 @@ const EXIT_CODES = {
|
|
|
69
84
|
[ErrorCode.IndexFailed]: 8,
|
|
70
85
|
[ErrorCode.SkillInvalid]: 9,
|
|
71
86
|
[ErrorCode.SkillNotFound]: 9,
|
|
87
|
+
[ErrorCode.ApprovalRequired]: 10,
|
|
88
|
+
[ErrorCode.PlanApprovalRequired]: 10,
|
|
72
89
|
};
|
|
73
90
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
74
91
|
export function exitCodeFor(code) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OnboardingState } from "./types.js";
|
|
2
|
+
/** `~/.cruxy/onboarding.json` */
|
|
3
|
+
export declare function onboardingStatePath(): string;
|
|
4
|
+
/** Read persisted onboarding state, or `null` if absent/unreadable. */
|
|
5
|
+
export declare function readOnboardingState(file?: string): OnboardingState | null;
|
|
6
|
+
/** Persist onboarding state (creates `~/.cruxy` if needed). */
|
|
7
|
+
export declare function writeOnboardingState(state: OnboardingState, file?: string): void;
|
|
8
|
+
/** A fresh state object. */
|
|
9
|
+
export declare function newOnboardingState(): OnboardingState;
|
|
10
|
+
/** Whether onboarding has been completed (the marker exists). */
|
|
11
|
+
export declare function onboardingCompleted(file?: string): boolean;
|
|
12
|
+
export interface FirstRunInput {
|
|
13
|
+
readonly provider: string;
|
|
14
|
+
/** Whether stdin is a TTY (the gate — non-TTY is never first-run). */
|
|
15
|
+
readonly interactive: boolean;
|
|
16
|
+
/** Override key resolution (tests); defaults to {@link resolveApiKey}. */
|
|
17
|
+
readonly resolveKey?: (provider: string) => string | undefined;
|
|
18
|
+
/** Override completion check (tests); defaults to {@link onboardingCompleted}. */
|
|
19
|
+
readonly completed?: () => boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* True iff we should launch the guided first-run flow: interactive **and** no
|
|
23
|
+
* resolvable key **and** no completion marker. Any one being false means we do
|
|
24
|
+
* not onboard (non-TTY fails loud elsewhere; a marker means "don't nag again").
|
|
25
|
+
*/
|
|
26
|
+
export declare function isFirstRun(input: FirstRunInput): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ONBOARDING_FILE_NAME } from "../constants.js";
|
|
4
|
+
import { globalDir, resolveApiKey } from "../config/index.js";
|
|
5
|
+
/**
|
|
6
|
+
* First-run detection + onboarding-state persistence (U.6). "First run" is
|
|
7
|
+
* deliberately derived from observable facts (no key + no completion marker)
|
|
8
|
+
* rather than a flag, and is **TTY-gated** so a non-interactive run never
|
|
9
|
+
* branches into an interactive flow.
|
|
10
|
+
*/
|
|
11
|
+
const ONBOARDING_VERSION = 1;
|
|
12
|
+
/** `~/.cruxy/onboarding.json` */
|
|
13
|
+
export function onboardingStatePath() {
|
|
14
|
+
return join(globalDir(), ONBOARDING_FILE_NAME);
|
|
15
|
+
}
|
|
16
|
+
/** Read persisted onboarding state, or `null` if absent/unreadable. */
|
|
17
|
+
export function readOnboardingState(file = onboardingStatePath()) {
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
22
|
+
if (parsed && typeof parsed === "object")
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A corrupt marker is treated as absent — onboarding can rewrite it.
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Persist onboarding state (creates `~/.cruxy` if needed). */
|
|
31
|
+
export function writeOnboardingState(state, file = onboardingStatePath()) {
|
|
32
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
33
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
34
|
+
}
|
|
35
|
+
/** A fresh state object. */
|
|
36
|
+
export function newOnboardingState() {
|
|
37
|
+
return { version: ONBOARDING_VERSION };
|
|
38
|
+
}
|
|
39
|
+
/** Whether onboarding has been completed (the marker exists). */
|
|
40
|
+
export function onboardingCompleted(file = onboardingStatePath()) {
|
|
41
|
+
return Boolean(readOnboardingState(file)?.completedAt);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True iff we should launch the guided first-run flow: interactive **and** no
|
|
45
|
+
* resolvable key **and** no completion marker. Any one being false means we do
|
|
46
|
+
* not onboard (non-TTY fails loud elsewhere; a marker means "don't nag again").
|
|
47
|
+
*/
|
|
48
|
+
export function isFirstRun(input) {
|
|
49
|
+
if (!input.interactive)
|
|
50
|
+
return false;
|
|
51
|
+
const resolveKey = input.resolveKey ?? resolveApiKey;
|
|
52
|
+
if (resolveKey(input.provider))
|
|
53
|
+
return false;
|
|
54
|
+
const completed = input.completed ?? onboardingCompleted;
|
|
55
|
+
return !completed();
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { OnboardingDeps, OnboardingOptions, OnboardingResult, ValidationOutcome } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
|
|
5
|
+
* step is skipped when a key already resolves; the completion marker is written
|
|
6
|
+
* only once a key is in place. An abort during the (mandatory) key step returns
|
|
7
|
+
* `{ aborted: true }` and writes **no** marker, so it resumes next time.
|
|
8
|
+
*/
|
|
9
|
+
export declare function runOnboarding(opts: OnboardingOptions): Promise<OnboardingResult>;
|
|
10
|
+
/** Dependencies for the default (production) wiring. */
|
|
11
|
+
export interface DefaultDepsOptions {
|
|
12
|
+
config: CruxyConfig;
|
|
13
|
+
cwd: string;
|
|
14
|
+
/** Runs the first-win task; omit to disable that step. */
|
|
15
|
+
runTask?: (prompt: string) => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build the production {@link OnboardingDeps}: live gateway validation, the
|
|
19
|
+
* credentials store, real state persistence, and a wall-clock timestamp.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createDefaultDeps(opts: DefaultDepsOptions): OnboardingDeps;
|
|
22
|
+
/**
|
|
23
|
+
* Validate a key with one cheap live call: start a 1-token stream and look at the
|
|
24
|
+
* first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
|
|
25
|
+
* anything else (a token, rate-limit, transient API error) means the key was
|
|
26
|
+
* accepted, so ⇒ valid.
|
|
27
|
+
*/
|
|
28
|
+
export declare function validateKeyLive(provider: string, apiKey: string, config: CruxyConfig): Promise<ValidationOutcome>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { AuthError, NetworkError, createProvider } from "@cruxy/sdk";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { resolveApiKey, writeCredential } from "../config/index.js";
|
|
4
|
+
import { newOnboardingState, readOnboardingState, writeOnboardingState, } from "./detect.js";
|
|
5
|
+
import { acquireKeyStep, firstWinStep, scaffoldStep } from "./steps.js";
|
|
6
|
+
/**
|
|
7
|
+
* Orchestrate the onboarding steps (U.6) — resumable and idempotent. The key
|
|
8
|
+
* step is skipped when a key already resolves; the completion marker is written
|
|
9
|
+
* only once a key is in place. An abort during the (mandatory) key step returns
|
|
10
|
+
* `{ aborted: true }` and writes **no** marker, so it resumes next time.
|
|
11
|
+
*/
|
|
12
|
+
export async function runOnboarding(opts) {
|
|
13
|
+
const { io, deps, provider } = opts;
|
|
14
|
+
const col = pc.createColors(io.color);
|
|
15
|
+
io.write(`${col.cyan(col.bold("Welcome to cruxy"))} — let's get you set up.\n`);
|
|
16
|
+
let state = deps.readState() ?? newOnboardingState();
|
|
17
|
+
let apiKey = deps.resolveApiKey(provider);
|
|
18
|
+
// ── key (mandatory; skipped if already resolvable unless forceKey) ─────────
|
|
19
|
+
if (!apiKey || opts.forceKey) {
|
|
20
|
+
const result = await acquireKeyStep(io, deps, provider);
|
|
21
|
+
if (result.status === "aborted") {
|
|
22
|
+
return { completed: false, aborted: true };
|
|
23
|
+
}
|
|
24
|
+
if (result.status !== "ok") {
|
|
25
|
+
// Failed (unreachable / rejected) — surface guidance, no marker.
|
|
26
|
+
if (result.message)
|
|
27
|
+
io.write(`${col.dim(result.message)}\n`);
|
|
28
|
+
return { completed: false, aborted: false };
|
|
29
|
+
}
|
|
30
|
+
apiKey = result.apiKey;
|
|
31
|
+
state = { ...state, keyConfigured: true };
|
|
32
|
+
deps.writeState(state);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
io.write(`${col.green("✓")} using your existing API key.\n`);
|
|
36
|
+
state = { ...state, keyConfigured: true };
|
|
37
|
+
}
|
|
38
|
+
// ── optional steps (Ctrl-C here just skips them; the key is already safe) ───
|
|
39
|
+
if (opts.offerScaffold)
|
|
40
|
+
await scaffoldStep(io, deps.cwd);
|
|
41
|
+
if (opts.offerFirstWin)
|
|
42
|
+
await firstWinStep(io, deps);
|
|
43
|
+
// ── complete ───────────────────────────────────────────────────────────────
|
|
44
|
+
state = { ...state, completedAt: deps.now() };
|
|
45
|
+
deps.writeState(state);
|
|
46
|
+
io.write(`${col.green(col.bold("✓ all set"))} — happy hacking.\n`);
|
|
47
|
+
return { completed: true, aborted: false, apiKey };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build the production {@link OnboardingDeps}: live gateway validation, the
|
|
51
|
+
* credentials store, real state persistence, and a wall-clock timestamp.
|
|
52
|
+
*/
|
|
53
|
+
export function createDefaultDeps(opts) {
|
|
54
|
+
return {
|
|
55
|
+
validateKey: (provider, apiKey) => validateKeyLive(provider, apiKey, opts.config),
|
|
56
|
+
writeCredential,
|
|
57
|
+
resolveApiKey,
|
|
58
|
+
readState: () => readOnboardingState(),
|
|
59
|
+
writeState: (state) => writeOnboardingState(state),
|
|
60
|
+
runTask: opts.runTask,
|
|
61
|
+
cwd: opts.cwd,
|
|
62
|
+
now: () => new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Validate a key with one cheap live call: start a 1-token stream and look at the
|
|
67
|
+
* first event. `AuthError` ⇒ invalid (bad key), `NetworkError` ⇒ unreachable;
|
|
68
|
+
* anything else (a token, rate-limit, transient API error) means the key was
|
|
69
|
+
* accepted, so ⇒ valid.
|
|
70
|
+
*/
|
|
71
|
+
export async function validateKeyLive(provider, apiKey, config) {
|
|
72
|
+
try {
|
|
73
|
+
const client = createProvider({
|
|
74
|
+
provider,
|
|
75
|
+
apiKey,
|
|
76
|
+
model: config.model.model,
|
|
77
|
+
maxTokens: 1,
|
|
78
|
+
gatewayUrl: config.cruxy.gatewayUrl,
|
|
79
|
+
});
|
|
80
|
+
for await (const ev of client.stream({
|
|
81
|
+
messages: [{ role: "user", content: "hi" }],
|
|
82
|
+
})) {
|
|
83
|
+
if (ev.type === "error")
|
|
84
|
+
return classifyValidation(ev.error);
|
|
85
|
+
return "valid"; // any non-error event ⇒ the key was accepted
|
|
86
|
+
}
|
|
87
|
+
return "valid";
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
return classifyValidation(err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function classifyValidation(err) {
|
|
94
|
+
if (err instanceof AuthError)
|
|
95
|
+
return "invalid";
|
|
96
|
+
if (err instanceof NetworkError)
|
|
97
|
+
return "unreachable";
|
|
98
|
+
// Rate-limit / overloaded / other API errors mean the key authenticated.
|
|
99
|
+
return "valid";
|
|
100
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { OnboardingIO } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
|
|
4
|
+
* stays clean for piping); the secret reader echoes `*` per keystroke and never
|
|
5
|
+
* the real character. All readers restore cooked mode on the way out, even on
|
|
6
|
+
* Ctrl-C / EOF — the terminal is never left in raw mode.
|
|
7
|
+
*/
|
|
8
|
+
export declare function defaultOnboardingIO(color?: boolean): OnboardingIO;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The real stdin/stderr-backed {@link OnboardingIO}. Prompts go to stderr (stdout
|
|
4
|
+
* stays clean for piping); the secret reader echoes `*` per keystroke and never
|
|
5
|
+
* the real character. All readers restore cooked mode on the way out, even on
|
|
6
|
+
* Ctrl-C / EOF — the terminal is never left in raw mode.
|
|
7
|
+
*/
|
|
8
|
+
export function defaultOnboardingIO(color = shouldUseColor()) {
|
|
9
|
+
return {
|
|
10
|
+
write: (text) => void process.stderr.write(text),
|
|
11
|
+
readLine: readLineFromStdin,
|
|
12
|
+
readKey: readKeyFromStdin,
|
|
13
|
+
readSecret: readSecretFromStdin,
|
|
14
|
+
color,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const CTRL_C = 0x03;
|
|
18
|
+
const CTRL_D = 0x04;
|
|
19
|
+
const BACKSPACE = 0x08;
|
|
20
|
+
const DELETE = 0x7f;
|
|
21
|
+
const LF = 0x0a;
|
|
22
|
+
const CR = 0x0d;
|
|
23
|
+
/** Read one keypress in raw mode; "" on EOF / Ctrl-C / Ctrl-D. Restores cooked mode. */
|
|
24
|
+
function readKeyFromStdin() {
|
|
25
|
+
const stdin = process.stdin;
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
const cleanup = () => {
|
|
28
|
+
stdin.removeListener("data", onData);
|
|
29
|
+
stdin.removeListener("end", onEnd);
|
|
30
|
+
if (stdin.isTTY)
|
|
31
|
+
stdin.setRawMode(false);
|
|
32
|
+
stdin.pause();
|
|
33
|
+
};
|
|
34
|
+
const onData = (buf) => {
|
|
35
|
+
cleanup();
|
|
36
|
+
const code = buf[0];
|
|
37
|
+
resolve(code === CTRL_C || code === CTRL_D
|
|
38
|
+
? ""
|
|
39
|
+
: buf.toString("utf8").slice(0, 1));
|
|
40
|
+
};
|
|
41
|
+
const onEnd = () => {
|
|
42
|
+
cleanup();
|
|
43
|
+
resolve("");
|
|
44
|
+
};
|
|
45
|
+
if (stdin.isTTY)
|
|
46
|
+
stdin.setRawMode(true);
|
|
47
|
+
stdin.resume();
|
|
48
|
+
stdin.once("data", onData);
|
|
49
|
+
stdin.once("end", onEnd);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** Read one line in cooked mode; "" on EOF. */
|
|
53
|
+
function readLineFromStdin() {
|
|
54
|
+
const stdin = process.stdin;
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
let buf = "";
|
|
57
|
+
const cleanup = () => {
|
|
58
|
+
stdin.removeListener("data", onData);
|
|
59
|
+
stdin.removeListener("end", onEnd);
|
|
60
|
+
stdin.pause();
|
|
61
|
+
};
|
|
62
|
+
const onData = (chunk) => {
|
|
63
|
+
buf += chunk.toString("utf8");
|
|
64
|
+
const nl = buf.indexOf("\n");
|
|
65
|
+
if (nl !== -1) {
|
|
66
|
+
cleanup();
|
|
67
|
+
resolve(buf.slice(0, nl).replace(/\r$/, ""));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const onEnd = () => {
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve(buf.replace(/\r$/, ""));
|
|
73
|
+
};
|
|
74
|
+
if (stdin.isTTY)
|
|
75
|
+
stdin.setRawMode(false);
|
|
76
|
+
stdin.resume();
|
|
77
|
+
stdin.on("data", onData);
|
|
78
|
+
stdin.once("end", onEnd);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read a secret with no echo: each printable keystroke shows a `*`, backspace
|
|
83
|
+
* erases one, Enter submits, Ctrl-C / Ctrl-D / EOF resolve "" (abort). The real
|
|
84
|
+
* characters are never written anywhere.
|
|
85
|
+
*/
|
|
86
|
+
function readSecretFromStdin() {
|
|
87
|
+
const stdin = process.stdin;
|
|
88
|
+
const out = process.stderr;
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
let buf = "";
|
|
91
|
+
let done = false;
|
|
92
|
+
const cleanup = () => {
|
|
93
|
+
stdin.removeListener("data", onData);
|
|
94
|
+
stdin.removeListener("end", onEnd);
|
|
95
|
+
if (stdin.isTTY)
|
|
96
|
+
stdin.setRawMode(false);
|
|
97
|
+
stdin.pause();
|
|
98
|
+
};
|
|
99
|
+
const finish = (value) => {
|
|
100
|
+
if (done)
|
|
101
|
+
return;
|
|
102
|
+
done = true;
|
|
103
|
+
cleanup();
|
|
104
|
+
out.write("\n");
|
|
105
|
+
resolve(value);
|
|
106
|
+
};
|
|
107
|
+
const onData = (chunk) => {
|
|
108
|
+
for (const byte of chunk) {
|
|
109
|
+
if (byte === CR || byte === LF)
|
|
110
|
+
return finish(buf); // Enter → submit
|
|
111
|
+
if (byte === CTRL_C || byte === CTRL_D)
|
|
112
|
+
return finish(""); // abort
|
|
113
|
+
if (byte === DELETE || byte === BACKSPACE) {
|
|
114
|
+
if (buf.length > 0) {
|
|
115
|
+
buf = buf.slice(0, -1);
|
|
116
|
+
out.write("\b \b"); // erase one star
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (byte < 0x20)
|
|
121
|
+
continue; // ignore other control chars
|
|
122
|
+
buf += String.fromCharCode(byte);
|
|
123
|
+
out.write("*");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const onEnd = () => finish("");
|
|
127
|
+
if (stdin.isTTY)
|
|
128
|
+
stdin.setRawMode(true);
|
|
129
|
+
stdin.resume();
|
|
130
|
+
stdin.on("data", onData);
|
|
131
|
+
stdin.once("end", onEnd);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OnboardingDeps, OnboardingIO, StepResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Acquire and persist a provider key: print the create-key URL, read it masked,
|
|
4
|
+
* validate it live, and **only then** write it to the credentials store. Loops on
|
|
5
|
+
* a rejected key (up to 3 tries); a network failure or an empty entry stops.
|
|
6
|
+
*/
|
|
7
|
+
export declare function acquireKeyStep(io: OnboardingIO, deps: OnboardingDeps, provider: string): Promise<StepResult>;
|
|
8
|
+
/**
|
|
9
|
+
* Offer to scaffold a project `CRUXY.md`. Skipped silently when one already
|
|
10
|
+
* exists (or `AGENTS.md`); otherwise a `y` confirmation writes the template.
|
|
11
|
+
*/
|
|
12
|
+
export declare function scaffoldStep(io: OnboardingIO, cwd: string): Promise<StepResult>;
|
|
13
|
+
/**
|
|
14
|
+
* Offer the first-win demo run. Skipped when no runner is wired or the user
|
|
15
|
+
* declines; otherwise runs one real task so they see value immediately.
|
|
16
|
+
*/
|
|
17
|
+
export declare function firstWinStep(io: OnboardingIO, deps: OnboardingDeps): Promise<StepResult>;
|