@cruxy/cli 0.6.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 +33 -16
- package/dist/agent/loop.d.ts +2 -0
- package/dist/agent/loop.js +1 -0
- package/dist/agent/prompts.d.ts +2 -0
- package/dist/agent/prompts.js +6 -0
- package/dist/agent/session.d.ts +24 -0
- package/dist/agent/session.js +33 -7
- 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/run.js +31 -53
- package/dist/cli/onboard.d.ts +25 -0
- package/dist/cli/onboard.js +54 -0
- package/dist/cli/program.js +19 -1
- package/dist/cli/repl.js +9 -0
- 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 +10 -0
- package/dist/config/schema.js +2 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/errors/constructors.d.ts +10 -0
- package/dist/errors/constructors.js +46 -2
- package/dist/errors/types.d.ts +3 -0
- package/dist/errors/types.js +6 -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/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createProvider } from "@cruxy/sdk";
|
|
2
|
+
import { loadProjectInstructions } from "../config/index.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { getGitInfo } from "../utils/git.js";
|
|
5
|
+
import { ApprovalService, InteractivePolicy, SessionAllowlist, defaultPromptIO, } from "../approval/index.js";
|
|
6
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
7
|
+
import { buildDefaultRegistry } from "../tools/index.js";
|
|
8
|
+
import { Session } from "../agent/index.js";
|
|
9
|
+
import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
|
|
10
|
+
/**
|
|
11
|
+
* Build a ready-to-run agent {@link Session} from a resolved key — the wiring
|
|
12
|
+
* shared by `cruxy run` and the onboarding first-win task (so they can't drift).
|
|
13
|
+
* The approval gate's interactivity tracks the TTY, exactly as in `run`.
|
|
14
|
+
*
|
|
15
|
+
* When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
|
|
16
|
+
* over the shared U.3 allowlist and a `planRunner` so `session.send` proposes →
|
|
17
|
+
* approves → executes. Plan mode is fully opt-in; the default path is unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode = false) {
|
|
20
|
+
const provider = createProvider({
|
|
21
|
+
provider: config.model.provider,
|
|
22
|
+
apiKey,
|
|
23
|
+
model: config.model.model,
|
|
24
|
+
maxTokens: config.model.maxTokens,
|
|
25
|
+
temperature: config.model.temperature,
|
|
26
|
+
gatewayUrl: config.cruxy.gatewayUrl,
|
|
27
|
+
});
|
|
28
|
+
const execRegistry = buildDefaultRegistry();
|
|
29
|
+
const git = getGitInfo(cwd);
|
|
30
|
+
const projectInstructions = loadProjectInstructions(cwd);
|
|
31
|
+
if (planMode) {
|
|
32
|
+
// One io + allowlist shared by the plan-approval prompt and the per-action
|
|
33
|
+
// gate, so a grant recorded during execution is honored by U.3's own check.
|
|
34
|
+
const io = defaultPromptIO(shouldUseColor());
|
|
35
|
+
const allowlist = new SessionAllowlist();
|
|
36
|
+
const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
|
|
37
|
+
const approval = new ApprovalService({
|
|
38
|
+
cwd,
|
|
39
|
+
interactive: ttyInteractive,
|
|
40
|
+
policy: planPolicy,
|
|
41
|
+
io,
|
|
42
|
+
});
|
|
43
|
+
const ctx = {
|
|
44
|
+
cwd,
|
|
45
|
+
config,
|
|
46
|
+
logger,
|
|
47
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
48
|
+
};
|
|
49
|
+
const planRunner = ({ messages, projectInstructions, onText, }) => runPlanSession({
|
|
50
|
+
provider,
|
|
51
|
+
config,
|
|
52
|
+
ctx,
|
|
53
|
+
execRegistry,
|
|
54
|
+
planPolicy,
|
|
55
|
+
io,
|
|
56
|
+
interactive: ttyInteractive,
|
|
57
|
+
messages,
|
|
58
|
+
git,
|
|
59
|
+
projectInstructions,
|
|
60
|
+
onText,
|
|
61
|
+
});
|
|
62
|
+
return new Session({
|
|
63
|
+
provider,
|
|
64
|
+
registry: execRegistry,
|
|
65
|
+
config,
|
|
66
|
+
ctx,
|
|
67
|
+
git,
|
|
68
|
+
projectInstructions,
|
|
69
|
+
planMode: true,
|
|
70
|
+
planRunner,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const approval = new ApprovalService({ cwd, interactive: ttyInteractive });
|
|
74
|
+
const ctx = {
|
|
75
|
+
cwd,
|
|
76
|
+
config,
|
|
77
|
+
logger,
|
|
78
|
+
requestApproval: (action) => approval.requestApproval(action),
|
|
79
|
+
};
|
|
80
|
+
return new Session({
|
|
81
|
+
provider,
|
|
82
|
+
registry: execRegistry,
|
|
83
|
+
config,
|
|
84
|
+
ctx,
|
|
85
|
+
git,
|
|
86
|
+
projectInstructions,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `~/.cruxy/credentials.json` */
|
|
2
|
+
export declare function credentialsPath(): string;
|
|
3
|
+
/** The stored key for `provider`, or `undefined`. Never throws. */
|
|
4
|
+
export declare function readCredential(provider: string, file?: string): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Persist `key` for `provider`, merging into any existing store. The file is
|
|
7
|
+
* written `0600` and its directory `0700` so the secret is owner-only — enforced
|
|
8
|
+
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
9
|
+
*/
|
|
10
|
+
export declare function writeCredential(provider: string, key: string, file?: string): void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { CREDENTIALS_FILE_NAME } from "../constants.js";
|
|
4
|
+
import { globalDir } from "./paths.js";
|
|
5
|
+
/**
|
|
6
|
+
* The credentials store (U.6) — the one place a provider API key is persisted.
|
|
7
|
+
* It lives **outside** `config.json` on purpose: config is secret-free by design
|
|
8
|
+
* ("the API key comes from env"), so secrets get their own file with restrictive
|
|
9
|
+
* permissions (`0600`, dir `0700`), the same shape as gh/aws/npm. `resolveApiKey`
|
|
10
|
+
* reads it as a fallback after the environment.
|
|
11
|
+
*/
|
|
12
|
+
/** Bumped if the on-disk shape ever changes. */
|
|
13
|
+
const CREDENTIALS_VERSION = 1;
|
|
14
|
+
/** `~/.cruxy/credentials.json` */
|
|
15
|
+
export function credentialsPath() {
|
|
16
|
+
return join(globalDir(), CREDENTIALS_FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
/** Parse the store at `file`, or `null` if absent/unreadable/malformed. */
|
|
19
|
+
function readStore(file) {
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
24
|
+
if (parsed &&
|
|
25
|
+
typeof parsed === "object" &&
|
|
26
|
+
"keys" in parsed &&
|
|
27
|
+
typeof parsed.keys === "object") {
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A corrupt store is treated as absent — onboarding can rewrite it.
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/** The stored key for `provider`, or `undefined`. Never throws. */
|
|
37
|
+
export function readCredential(provider, file = credentialsPath()) {
|
|
38
|
+
const store = readStore(file);
|
|
39
|
+
const key = store?.keys[provider];
|
|
40
|
+
return typeof key === "string" && key !== "" ? key : undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Persist `key` for `provider`, merging into any existing store. The file is
|
|
44
|
+
* written `0600` and its directory `0700` so the secret is owner-only — enforced
|
|
45
|
+
* with an explicit `chmod` after write (mkdir/write modes are umask-masked).
|
|
46
|
+
*/
|
|
47
|
+
export function writeCredential(provider, key, file = credentialsPath()) {
|
|
48
|
+
const dir = dirname(file);
|
|
49
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
try {
|
|
51
|
+
chmodSync(dir, 0o700);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Best-effort on platforms without POSIX modes (e.g. Windows).
|
|
55
|
+
}
|
|
56
|
+
const store = readStore(file) ?? { version: CREDENTIALS_VERSION, keys: {} };
|
|
57
|
+
store.version = CREDENTIALS_VERSION;
|
|
58
|
+
store.keys[provider] = key;
|
|
59
|
+
writeFileSync(file, JSON.stringify(store, null, 2) + "\n", {
|
|
60
|
+
encoding: "utf8",
|
|
61
|
+
mode: 0o600,
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
chmodSync(file, 0o600);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Best-effort (see above).
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/config/index.d.ts
CHANGED
package/dist/config/index.js
CHANGED
package/dist/config/manager.d.ts
CHANGED
|
@@ -30,5 +30,10 @@ export declare function initConfig(file: string): {
|
|
|
30
30
|
path: string;
|
|
31
31
|
created: boolean;
|
|
32
32
|
};
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Provider API key, resolved **env → credentials store → undefined**. The
|
|
35
|
+
* environment always wins (CI / one-off overrides); the credentials store
|
|
36
|
+
* (`~/.cruxy/credentials.json`, written by onboarding / `cruxy login`) is the
|
|
37
|
+
* persistent fallback. The key is never read from `config.json`.
|
|
38
|
+
*/
|
|
34
39
|
export declare function resolveApiKey(provider: string): string | undefined;
|
package/dist/config/manager.js
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { configInvalid, configParse } from "../errors/index.js";
|
|
4
4
|
import { CruxyConfigSchema } from "./schema.js";
|
|
5
5
|
import { globalConfigPath, findProjectConfig } from "./paths.js";
|
|
6
|
+
import { readCredential } from "./credentials.js";
|
|
6
7
|
function isPlainObject(v) {
|
|
7
8
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
8
9
|
}
|
|
@@ -137,8 +138,17 @@ export function initConfig(file) {
|
|
|
137
138
|
writeFileSync(file, JSON.stringify(defaults, null, 2) + "\n", "utf8");
|
|
138
139
|
return { path: file, created: true };
|
|
139
140
|
}
|
|
140
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Provider API key, resolved **env → credentials store → undefined**. The
|
|
143
|
+
* environment always wins (CI / one-off overrides); the credentials store
|
|
144
|
+
* (`~/.cruxy/credentials.json`, written by onboarding / `cruxy login`) is the
|
|
145
|
+
* persistent fallback. The key is never read from `config.json`.
|
|
146
|
+
*/
|
|
141
147
|
export function resolveApiKey(provider) {
|
|
148
|
+
return envApiKey(provider) ?? readCredential(provider);
|
|
149
|
+
}
|
|
150
|
+
/** The API key from the environment for `provider`, or `undefined`. */
|
|
151
|
+
function envApiKey(provider) {
|
|
142
152
|
switch (provider) {
|
|
143
153
|
case "cruxy":
|
|
144
154
|
return process.env.CRUXY_API_KEY;
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -30,12 +30,16 @@ export declare const AgentConfigSchema: z.ZodObject<{
|
|
|
30
30
|
maxIterations: z.ZodDefault<z.ZodNumber>;
|
|
31
31
|
/** Skip per-action confirmation prompts. */
|
|
32
32
|
autoApprove: z.ZodDefault<z.ZodBoolean>;
|
|
33
|
+
/** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
|
|
34
|
+
planMode: z.ZodDefault<z.ZodBoolean>;
|
|
33
35
|
}, "strict", z.ZodTypeAny, {
|
|
34
36
|
maxIterations: number;
|
|
35
37
|
autoApprove: boolean;
|
|
38
|
+
planMode: boolean;
|
|
36
39
|
}, {
|
|
37
40
|
maxIterations?: number | undefined;
|
|
38
41
|
autoApprove?: boolean | undefined;
|
|
42
|
+
planMode?: boolean | undefined;
|
|
39
43
|
}>;
|
|
40
44
|
export declare const ToolsConfigSchema: z.ZodObject<{
|
|
41
45
|
fileEdit: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -242,12 +246,16 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
242
246
|
maxIterations: z.ZodDefault<z.ZodNumber>;
|
|
243
247
|
/** Skip per-action confirmation prompts. */
|
|
244
248
|
autoApprove: z.ZodDefault<z.ZodBoolean>;
|
|
249
|
+
/** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
|
|
250
|
+
planMode: z.ZodDefault<z.ZodBoolean>;
|
|
245
251
|
}, "strict", z.ZodTypeAny, {
|
|
246
252
|
maxIterations: number;
|
|
247
253
|
autoApprove: boolean;
|
|
254
|
+
planMode: boolean;
|
|
248
255
|
}, {
|
|
249
256
|
maxIterations?: number | undefined;
|
|
250
257
|
autoApprove?: boolean | undefined;
|
|
258
|
+
planMode?: boolean | undefined;
|
|
251
259
|
}>>;
|
|
252
260
|
tools: z.ZodDefault<z.ZodObject<{
|
|
253
261
|
fileEdit: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -428,6 +436,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
428
436
|
agent: {
|
|
429
437
|
maxIterations: number;
|
|
430
438
|
autoApprove: boolean;
|
|
439
|
+
planMode: boolean;
|
|
431
440
|
};
|
|
432
441
|
tools: {
|
|
433
442
|
fileEdit: boolean;
|
|
@@ -485,6 +494,7 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
485
494
|
agent?: {
|
|
486
495
|
maxIterations?: number | undefined;
|
|
487
496
|
autoApprove?: boolean | undefined;
|
|
497
|
+
planMode?: boolean | undefined;
|
|
488
498
|
} | undefined;
|
|
489
499
|
tools?: {
|
|
490
500
|
fileEdit?: boolean | undefined;
|
package/dist/config/schema.js
CHANGED
|
@@ -28,6 +28,8 @@ export const AgentConfigSchema = z
|
|
|
28
28
|
maxIterations: z.number().int().positive().default(25),
|
|
29
29
|
/** Skip per-action confirmation prompts. */
|
|
30
30
|
autoApprove: z.boolean().default(false),
|
|
31
|
+
/** Plan mode: propose a plan for approval before executing (C.31, opt-in). */
|
|
32
|
+
planMode: z.boolean().default(false),
|
|
31
33
|
})
|
|
32
34
|
.strict();
|
|
33
35
|
export const ToolsConfigSchema = z
|
package/dist/constants.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export declare const APP_DESCRIPTION: string;
|
|
|
5
5
|
/** Directory/file names cruxy looks for. */
|
|
6
6
|
export declare const GLOBAL_DIR_NAME = ".cruxy";
|
|
7
7
|
export declare const CONFIG_FILE_NAME = "config.json";
|
|
8
|
+
/** Secret store under the global dir — `0600`, never holds non-secret config. */
|
|
9
|
+
export declare const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
10
|
+
/** Onboarding state + completion marker under the global dir (U.6). */
|
|
11
|
+
export declare const ONBOARDING_FILE_NAME = "onboarding.json";
|
|
12
|
+
/** Where a user creates a Cruxy gateway key (printed during onboarding). */
|
|
13
|
+
export declare const CREATE_KEY_URL = "https://app.cruxy.in";
|
|
8
14
|
/** Project-level config filenames, checked in order. */
|
|
9
15
|
export declare const PROJECT_CONFIG_FILENAMES: string[];
|
|
10
16
|
/** Project-instruction filenames, checked in order (first match wins). */
|
package/dist/constants.js
CHANGED
|
@@ -22,6 +22,12 @@ export const APP_DESCRIPTION = pkg.description ?? "an agentic coding CLI";
|
|
|
22
22
|
/** Directory/file names cruxy looks for. */
|
|
23
23
|
export const GLOBAL_DIR_NAME = ".cruxy";
|
|
24
24
|
export const CONFIG_FILE_NAME = "config.json";
|
|
25
|
+
/** Secret store under the global dir — `0600`, never holds non-secret config. */
|
|
26
|
+
export const CREDENTIALS_FILE_NAME = "credentials.json";
|
|
27
|
+
/** Onboarding state + completion marker under the global dir (U.6). */
|
|
28
|
+
export const ONBOARDING_FILE_NAME = "onboarding.json";
|
|
29
|
+
/** Where a user creates a Cruxy gateway key (printed during onboarding). */
|
|
30
|
+
export const CREATE_KEY_URL = "https://app.cruxy.in";
|
|
25
31
|
/** Project-level config filenames, checked in order. */
|
|
26
32
|
export const PROJECT_CONFIG_FILENAMES = [
|
|
27
33
|
"cruxy.config.json",
|
|
@@ -43,6 +43,16 @@ export declare function forgeApi(title: string, underlying?: unknown, meta?: Rec
|
|
|
43
43
|
* `--no-verify`, so the underlying reason is surfaced verbatim.
|
|
44
44
|
*/
|
|
45
45
|
export declare function gitPushFailed(branch: string, stderr?: string): CruxyError;
|
|
46
|
+
/** The agent's proposed plan was missing or malformed (plan mode, C.31). */
|
|
47
|
+
export declare function planInvalid(reason: string): CruxyError;
|
|
48
|
+
/** The plan was rejected too many times without converging (plan mode, C.31). */
|
|
49
|
+
export declare function planRevisionLimit(limit: number): CruxyError;
|
|
50
|
+
/**
|
|
51
|
+
* Plan mode needs interactive approval but cruxy is running non-interactively.
|
|
52
|
+
* Default-deny — a plan is never auto-approved. Distinct code (exit 10) so CI can
|
|
53
|
+
* tell it apart from a per-action approval requirement.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planApprovalRequired(): CruxyError;
|
|
46
56
|
export declare function internal(underlying?: unknown): CruxyError;
|
|
47
57
|
/**
|
|
48
58
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
@@ -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
|
});
|
|
@@ -291,6 +292,49 @@ export function gitPushFailed(branch, stderr) {
|
|
|
291
292
|
meta: { branch },
|
|
292
293
|
});
|
|
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
|
+
}
|
|
294
338
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
295
339
|
export function internal(underlying) {
|
|
296
340
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const ErrorCode: {
|
|
|
17
17
|
readonly ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN";
|
|
18
18
|
readonly ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED";
|
|
19
19
|
readonly GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH";
|
|
20
|
+
readonly PlanInvalid: "CRUXY_E_PLAN_INVALID";
|
|
21
|
+
readonly PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT";
|
|
20
22
|
readonly ConfigParse: "CRUXY_E_CONFIG_PARSE";
|
|
21
23
|
readonly ConfigInvalid: "CRUXY_E_CONFIG_INVALID";
|
|
22
24
|
readonly AuthMissingKey: "CRUXY_E_AUTH_MISSING_KEY";
|
|
@@ -38,6 +40,7 @@ export declare const ErrorCode: {
|
|
|
38
40
|
readonly SkillInvalid: "CRUXY_E_SKILL_INVALID";
|
|
39
41
|
readonly SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND";
|
|
40
42
|
readonly ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED";
|
|
43
|
+
readonly PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED";
|
|
41
44
|
};
|
|
42
45
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
43
46
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -19,6 +19,8 @@ export const ErrorCode = {
|
|
|
19
19
|
ConfigKeyUnknown: "CRUXY_E_CONFIG_KEY_UNKNOWN",
|
|
20
20
|
ProviderUnsupported: "CRUXY_E_PROVIDER_UNSUPPORTED",
|
|
21
21
|
GitProtectedBranch: "CRUXY_E_GIT_PROTECTED_BRANCH",
|
|
22
|
+
PlanInvalid: "CRUXY_E_PLAN_INVALID",
|
|
23
|
+
PlanRevisionLimit: "CRUXY_E_PLAN_REVISION_LIMIT",
|
|
22
24
|
// config (exit 3)
|
|
23
25
|
ConfigParse: "CRUXY_E_CONFIG_PARSE",
|
|
24
26
|
ConfigInvalid: "CRUXY_E_CONFIG_INVALID",
|
|
@@ -48,6 +50,7 @@ export const ErrorCode = {
|
|
|
48
50
|
SkillNotFound: "CRUXY_E_SKILL_NOT_FOUND",
|
|
49
51
|
// approval (exit 10)
|
|
50
52
|
ApprovalRequired: "CRUXY_E_APPROVAL_REQUIRED",
|
|
53
|
+
PlanApprovalRequired: "CRUXY_E_PLAN_APPROVAL_REQUIRED",
|
|
51
54
|
};
|
|
52
55
|
/**
|
|
53
56
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -59,6 +62,8 @@ const EXIT_CODES = {
|
|
|
59
62
|
[ErrorCode.ConfigKeyUnknown]: 2,
|
|
60
63
|
[ErrorCode.ProviderUnsupported]: 2,
|
|
61
64
|
[ErrorCode.GitProtectedBranch]: 2,
|
|
65
|
+
[ErrorCode.PlanInvalid]: 2,
|
|
66
|
+
[ErrorCode.PlanRevisionLimit]: 2,
|
|
62
67
|
[ErrorCode.ConfigParse]: 3,
|
|
63
68
|
[ErrorCode.ConfigInvalid]: 3,
|
|
64
69
|
[ErrorCode.AuthMissingKey]: 4,
|
|
@@ -80,6 +85,7 @@ const EXIT_CODES = {
|
|
|
80
85
|
[ErrorCode.SkillInvalid]: 9,
|
|
81
86
|
[ErrorCode.SkillNotFound]: 9,
|
|
82
87
|
[ErrorCode.ApprovalRequired]: 10,
|
|
88
|
+
[ErrorCode.PlanApprovalRequired]: 10,
|
|
83
89
|
};
|
|
84
90
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
85
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
|
+
}
|