recess-cli 1.9.2 → 2.0.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.
@@ -0,0 +1,257 @@
1
+ import { CliError } from "./errors.js";
2
+ export const AGENT_CONTEXT_SCHEMA_VERSION = "3";
3
+ const BOOLEAN_FLAGS = new Set([
4
+ "all-references",
5
+ "allow-strand",
6
+ "archived",
7
+ "cancel-subscriptions",
8
+ "confirm",
9
+ "confirm-destructive-changes",
10
+ "disable-applet-follow-ups",
11
+ "dry-run",
12
+ "enable-applet-follow-ups",
13
+ "full",
14
+ "help",
15
+ "immediate",
16
+ "include-deleted",
17
+ "json",
18
+ "mirrored",
19
+ "no-collision",
20
+ "no-invite",
21
+ "refresh",
22
+ "restore",
23
+ "revoke",
24
+ "send-email",
25
+ "skill-only",
26
+ "spec-only",
27
+ "starter-only",
28
+ "visual-only",
29
+ "wait",
30
+ ]);
31
+ const REPEATABLE_FLAGS = new Set(["kid", "unassign"]);
32
+ const GLOBAL_FLAGS = new Set([
33
+ "deliver",
34
+ "help",
35
+ "json",
36
+ "operation-key",
37
+ "profile",
38
+ "reason",
39
+ ]);
40
+ const GLOBAL_FLAG_TYPES = {
41
+ deliver: "string",
42
+ help: "boolean",
43
+ json: "boolean",
44
+ "operation-key": "string",
45
+ profile: "string",
46
+ reason: "string",
47
+ };
48
+ function usageBlocks(help) {
49
+ const lines = help.split("\n");
50
+ const blocks = [];
51
+ let current = null;
52
+ for (const line of lines) {
53
+ if (line.startsWith(" recess ")) {
54
+ if (current)
55
+ blocks.push(current.join(" ").replace(/\s+/g, " ").trim());
56
+ current = [line.trim()];
57
+ continue;
58
+ }
59
+ if (current && /^\s{4,}\S/.test(line)) {
60
+ current.push(line.trim());
61
+ continue;
62
+ }
63
+ if (current) {
64
+ blocks.push(current.join(" ").replace(/\s+/g, " ").trim());
65
+ current = null;
66
+ }
67
+ }
68
+ if (current)
69
+ blocks.push(current.join(" ").replace(/\s+/g, " ").trim());
70
+ return blocks;
71
+ }
72
+ function commandPath(usage) {
73
+ const rest = usage.replace(/^recess\s+(?:\[--json\]\s+)?/, "");
74
+ const tokens = rest.split(/\s+/);
75
+ const path = [];
76
+ for (const token of tokens) {
77
+ if (token.startsWith("--") ||
78
+ token.startsWith("[") ||
79
+ token.startsWith("<") ||
80
+ token.startsWith("(") ||
81
+ token.startsWith('"')) {
82
+ break;
83
+ }
84
+ path.push(token);
85
+ }
86
+ if (path.length === 0 && rest.startsWith("--version"))
87
+ return [["--version"]];
88
+ let variants = [[]];
89
+ for (const token of path) {
90
+ const choices = token.split("|");
91
+ variants = variants.flatMap((prefix) => choices.map((choice) => [...prefix, choice]));
92
+ }
93
+ return variants;
94
+ }
95
+ function flagValues(usage, name) {
96
+ const match = usage.match(new RegExp(`--${name}(?:[ =])([A-Za-z0-9_-]+(?:\\|[A-Za-z0-9_-]+)+)`));
97
+ return match?.[1]?.split("|");
98
+ }
99
+ function flagIsIndividuallyRequired(usage, name) {
100
+ const marker = `--${name}`;
101
+ let squareDepth = 0;
102
+ let parenDepth = 0;
103
+ for (let index = 0; index < usage.length; index += 1) {
104
+ const character = usage[index];
105
+ if (character === "[")
106
+ squareDepth += 1;
107
+ if (character === "]")
108
+ squareDepth = Math.max(0, squareDepth - 1);
109
+ if (character === "(")
110
+ parenDepth += 1;
111
+ if (character === ")")
112
+ parenDepth = Math.max(0, parenDepth - 1);
113
+ if (usage.startsWith(marker, index) &&
114
+ !/[a-z0-9-]/.test(usage[index + marker.length] ?? "") &&
115
+ squareDepth === 0 &&
116
+ parenDepth === 0) {
117
+ return true;
118
+ }
119
+ }
120
+ return false;
121
+ }
122
+ function positionalBounds(usage) {
123
+ const rest = usage.replace(/^recess\s+(?:\[--json\]\s+)?/, "");
124
+ const tokens = rest.split(/\s+/);
125
+ let count = 0;
126
+ let variadic = false;
127
+ for (const token of tokens) {
128
+ if (token.startsWith("--") ||
129
+ token.startsWith("[") ||
130
+ token.startsWith("(")) {
131
+ break;
132
+ }
133
+ count += 1;
134
+ if (/^<[^,>]+\.\.\.>$/.test(token))
135
+ variadic = true;
136
+ }
137
+ return { min: count, max: variadic ? null : count };
138
+ }
139
+ export function buildCommandSchema(help) {
140
+ return usageBlocks(help).flatMap((usage) => {
141
+ const names = Array.from(usage.matchAll(/--([a-z][a-z0-9-]*)/g)).map((match) => match[1]);
142
+ const flags = Object.fromEntries(Array.from(new Set(names)).map((name) => [
143
+ name,
144
+ {
145
+ type: BOOLEAN_FLAGS.has(name) ? "boolean" : "string",
146
+ repeatable: REPEATABLE_FLAGS.has(name),
147
+ required: flagIsIndividuallyRequired(usage, name),
148
+ ...(flagValues(usage, name)
149
+ ? { values: flagValues(usage, name) }
150
+ : {}),
151
+ },
152
+ ]));
153
+ const positionals = positionalBounds(usage);
154
+ return commandPath(usage).map((path) => ({
155
+ path,
156
+ usage,
157
+ flags,
158
+ positionals,
159
+ }));
160
+ });
161
+ }
162
+ function pathStartsWith(path, prefix) {
163
+ return prefix.every((part, index) => path[index] === part);
164
+ }
165
+ export function findCommandSchema(commands, positionals) {
166
+ return commands
167
+ .filter((command) => pathStartsWith(positionals, command.path))
168
+ .sort((left, right) => right.path.length - left.path.length)[0];
169
+ }
170
+ export function scopedHelp(help, commands, scope) {
171
+ if (scope.length === 0)
172
+ return help;
173
+ const matches = commands.filter((command) => pathStartsWith(command.path, scope));
174
+ if (matches.length === 0) {
175
+ throw new CliError("unknown_command", `Unknown command scope: ${scope.join(" ")}. Run \`recess --help\` for available commands.`);
176
+ }
177
+ return [
178
+ `recess ${scope.join(" ")} — command help`,
179
+ "",
180
+ "Usage:",
181
+ ...Array.from(new Set(matches.map((command) => ` ${command.usage}`))),
182
+ ].join("\n");
183
+ }
184
+ export function validateInvocation(parsed, commands) {
185
+ const command = findCommandSchema(commands, parsed.positionals);
186
+ if (!command) {
187
+ const scope = parsed.positionals.join(" ");
188
+ const first = parsed.positionals[0];
189
+ const suggestions = commands
190
+ .filter((candidate) => !first || candidate.path[0] === first)
191
+ .slice(0, 12)
192
+ .map((candidate) => candidate.path.join(" "));
193
+ throw new CliError("unknown_command", `Unknown command: ${scope || "(none)"}.`, 1, suggestions.length > 0 ? { suggestions } : undefined);
194
+ }
195
+ const allowed = new Set([...Object.keys(command.flags), ...GLOBAL_FLAGS]);
196
+ const unknown = Array.from(parsed.flags.keys()).filter((name) => name !== "version" && !allowed.has(name));
197
+ if (unknown.length > 0) {
198
+ const validFlags = Array.from(allowed)
199
+ .sort()
200
+ .map((name) => `--${name}`);
201
+ throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
202
+ .map((name) => `--${name}`)
203
+ .join(", ")}.`, 1, { usage: command.usage, validFlags });
204
+ }
205
+ const duplicate = Array.from(parsed.occurrences).find(([name, count]) => count > 1 && !command.flags[name]?.repeatable);
206
+ if (duplicate) {
207
+ throw new CliError("duplicate_flag", `--${duplicate[0]} may only be passed once.`, 1, { usage: command.usage });
208
+ }
209
+ const positionalCount = parsed.positionals.length;
210
+ if (positionalCount < command.positionals.min ||
211
+ (command.positionals.max !== null &&
212
+ positionalCount > command.positionals.max)) {
213
+ throw new CliError("invalid_arguments", `Wrong number of positional arguments for ${command.path.join(" ")}.`, 1, { usage: command.usage });
214
+ }
215
+ for (const [name, value] of parsed.flags) {
216
+ const type = command.flags[name]?.type ?? GLOBAL_FLAG_TYPES[name];
217
+ if (type === "boolean" && value !== true) {
218
+ throw new CliError("invalid_arguments", `--${name} is a boolean flag and does not take a value.`, 1, { usage: command.usage });
219
+ }
220
+ if (type === "string" && value === true) {
221
+ throw new CliError("invalid_arguments", `--${name} requires a value.`, 1, { usage: command.usage });
222
+ }
223
+ }
224
+ return command;
225
+ }
226
+ export function agentContext(commands, options) {
227
+ return {
228
+ schema_version: AGENT_CONTEXT_SCHEMA_VERSION,
229
+ cli_version: options.cliVersion,
230
+ commands: Object.fromEntries(commands
231
+ .filter((command) => command.path[0] !== "--version")
232
+ .map((command) => [
233
+ command.path.join(" "),
234
+ {
235
+ usage: command.usage,
236
+ flags: command.flags,
237
+ positionals: command.positionals,
238
+ },
239
+ ])),
240
+ global_flags: {
241
+ "--json": { type: "boolean" },
242
+ "--profile": { type: "string" },
243
+ "--operation-key": { type: "string" },
244
+ "--reason": {
245
+ type: "string",
246
+ required_for: "every Recess API request except authentication",
247
+ max_length: 1024,
248
+ },
249
+ "--deliver": { type: "enum", values: ["stdout", "file:<path>"] },
250
+ "--help": { type: "boolean" },
251
+ },
252
+ available_profiles: options.availableProfiles,
253
+ jobs: { commands: ["jobs list", "jobs get", "jobs prune"] },
254
+ feedback: { upstream_configured: options.feedbackUpstreamConfigured },
255
+ };
256
+ }
257
+ //# sourceMappingURL=command-schema.js.map
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { CliError } from "./errors.js";
4
5
  export const DEFAULT_OAUTH_CLIENT_ID = "c7e34138-18f9-45b1-a2fb-26a4e3a6d739";
5
6
  export function defaultConfigPath() {
6
7
  return (process.env.RECESS_CLI_CONFIG ??
@@ -16,32 +17,111 @@ export async function readStoredConfig(configPath = defaultConfigPath()) {
16
17
  throw error;
17
18
  }
18
19
  }
19
- export async function resolveConfig() {
20
+ export async function resolveConfig(profileName) {
20
21
  const configPath = defaultConfigPath();
21
22
  const stored = await readStoredConfig(configPath);
23
+ const selectedProfileName = profileName ?? process.env.RECESS_CLI_PROFILE ?? stored.activeProfile;
24
+ const profile = selectedProfileName
25
+ ? stored.profiles?.[selectedProfileName]
26
+ : undefined;
27
+ if (selectedProfileName && !profile) {
28
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${selectedProfileName}. Run \`recess --json profile list\`.`);
29
+ }
22
30
  const envCookie = process.env.RECESS_CLI_COOKIE;
23
- const sessionCookie = envCookie ?? stored.sessionCookie;
31
+ const sessionCookie = envCookie ??
32
+ (selectedProfileName ? profile?.sessionCookie : stored.sessionCookie);
24
33
  return {
25
34
  configPath,
26
35
  apiOrigin: process.env.RECESS_CLI_API_ORIGIN ??
36
+ profile?.apiOrigin ??
27
37
  stored.apiOrigin ??
28
38
  "https://api.recess.gg",
29
39
  webOrigin: process.env.RECESS_CLI_WEB_ORIGIN ??
40
+ profile?.webOrigin ??
30
41
  stored.webOrigin ??
31
42
  "https://recess.gg",
32
43
  oauthClientId: process.env.RECESS_CLI_OAUTH_CLIENT_ID ??
44
+ profile?.oauthClientId ??
33
45
  stored.oauthClientId ??
34
46
  DEFAULT_OAUTH_CLIENT_ID,
35
47
  sessionCookie,
36
- sessionExpiresAt: stored.sessionExpiresAt,
37
- user: stored.user,
48
+ sessionExpiresAt: selectedProfileName
49
+ ? profile?.sessionExpiresAt
50
+ : stored.sessionExpiresAt,
51
+ user: selectedProfileName ? profile?.user : stored.user,
38
52
  authSource: envCookie ? "env" : sessionCookie ? "config" : "missing",
39
- pendingDeviceCode: stored.pendingDeviceCode,
40
- pendingUserCode: stored.pendingUserCode,
41
- pendingApprovalUrl: stored.pendingApprovalUrl,
42
- pendingExpiresAt: stored.pendingExpiresAt,
53
+ pendingDeviceCode: selectedProfileName
54
+ ? profile?.pendingDeviceCode
55
+ : stored.pendingDeviceCode,
56
+ pendingUserCode: selectedProfileName
57
+ ? profile?.pendingUserCode
58
+ : stored.pendingUserCode,
59
+ pendingApprovalUrl: selectedProfileName
60
+ ? profile?.pendingApprovalUrl
61
+ : stored.pendingApprovalUrl,
62
+ pendingExpiresAt: selectedProfileName
63
+ ? profile?.pendingExpiresAt
64
+ : stored.pendingExpiresAt,
65
+ profileName: selectedProfileName,
66
+ };
67
+ }
68
+ export function assertProfileName(name) {
69
+ const normalized = name.trim();
70
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(normalized)) {
71
+ throw new CliError("invalid_arguments", "Profile names must be 1-64 letters, numbers, underscores, or hyphens.");
72
+ }
73
+ return normalized;
74
+ }
75
+ export async function listProfiles() {
76
+ const stored = await readStoredConfig();
77
+ return {
78
+ activeProfile: stored.activeProfile ?? null,
79
+ profiles: Object.entries(stored.profiles ?? {})
80
+ .sort(([left], [right]) => left.localeCompare(right))
81
+ .map(([name, config]) => ({
82
+ name,
83
+ config: {
84
+ apiOrigin: config.apiOrigin,
85
+ webOrigin: config.webOrigin,
86
+ oauthClientId: config.oauthClientId,
87
+ },
88
+ })),
43
89
  };
44
90
  }
91
+ export async function saveProfile(name, profile) {
92
+ const normalized = assertProfileName(name);
93
+ const stored = await readStoredConfig();
94
+ await writeStoredConfig({
95
+ ...stored,
96
+ profiles: {
97
+ ...stored.profiles,
98
+ [normalized]: {
99
+ ...stored.profiles?.[normalized],
100
+ ...profile,
101
+ },
102
+ },
103
+ });
104
+ }
105
+ export async function useProfile(name) {
106
+ const normalized = assertProfileName(name);
107
+ const stored = await readStoredConfig();
108
+ if (!stored.profiles?.[normalized]) {
109
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${normalized}. Run \`recess --json profile list\`.`);
110
+ }
111
+ await writeStoredConfig({ ...stored, activeProfile: normalized });
112
+ }
113
+ export async function deleteProfile(name) {
114
+ const normalized = assertProfileName(name);
115
+ const stored = await readStoredConfig();
116
+ if (!stored.profiles?.[normalized]) {
117
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${normalized}.`);
118
+ }
119
+ const profiles = { ...stored.profiles };
120
+ delete profiles[normalized];
121
+ if (stored.activeProfile === normalized)
122
+ delete stored.activeProfile;
123
+ await writeStoredConfig({ ...stored, profiles });
124
+ }
45
125
  export async function writeStoredConfig(config, configPath = defaultConfigPath()) {
46
126
  const directory = path.dirname(configPath);
47
127
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -52,28 +132,45 @@ export async function writeStoredConfig(config, configPath = defaultConfigPath()
52
132
  await fs.rename(temporary, configPath);
53
133
  await fs.chmod(configPath, 0o600);
54
134
  }
55
- export async function updateStoredConfig(update) {
135
+ export async function updateStoredConfig(update, profileName) {
56
136
  const configPath = defaultConfigPath();
57
137
  const current = await readStoredConfig(configPath);
58
- const next = { ...current, ...update };
138
+ const next = profileName
139
+ ? {
140
+ ...current,
141
+ profiles: {
142
+ ...current.profiles,
143
+ [assertProfileName(profileName)]: {
144
+ ...current.profiles?.[profileName],
145
+ ...update,
146
+ },
147
+ },
148
+ }
149
+ : { ...current, ...update };
59
150
  await writeStoredConfig(next, configPath);
60
151
  return next;
61
152
  }
62
- export async function clearStoredSession() {
153
+ export async function clearStoredSession(profileName) {
63
154
  const configPath = defaultConfigPath();
64
155
  const current = await readStoredConfig(configPath);
65
- delete current.sessionCookie;
66
- delete current.sessionExpiresAt;
67
- delete current.user;
156
+ const target = profileName ? current.profiles?.[profileName] : current;
157
+ if (!target)
158
+ return;
159
+ delete target.sessionCookie;
160
+ delete target.sessionExpiresAt;
161
+ delete target.user;
68
162
  await writeStoredConfig(current, configPath);
69
163
  }
70
- export async function clearPendingDeviceAuth() {
164
+ export async function clearPendingDeviceAuth(profileName) {
71
165
  const configPath = defaultConfigPath();
72
166
  const current = await readStoredConfig(configPath);
73
- delete current.pendingDeviceCode;
74
- delete current.pendingUserCode;
75
- delete current.pendingApprovalUrl;
76
- delete current.pendingExpiresAt;
167
+ const target = profileName ? current.profiles?.[profileName] : current;
168
+ if (!target)
169
+ return;
170
+ delete target.pendingDeviceCode;
171
+ delete target.pendingUserCode;
172
+ delete target.pendingApprovalUrl;
173
+ delete target.pendingExpiresAt;
77
174
  await writeStoredConfig(current, configPath);
78
175
  }
79
176
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ export async function deliverJson(destination, envelope) {
5
+ if (!destination || destination === "stdout")
6
+ return { delivered: false };
7
+ if (!destination.startsWith("file:")) {
8
+ throw new CliError("invalid_delivery", "--deliver must be one of: stdout, file:<path>. Webhook delivery is deliberately unavailable for authenticated Recess data.");
9
+ }
10
+ const requestedPath = destination.slice("file:".length);
11
+ if (!requestedPath) {
12
+ throw new CliError("invalid_delivery", "file: delivery requires a path.");
13
+ }
14
+ const absolutePath = path.resolve(requestedPath);
15
+ const bytes = Buffer.from(`${JSON.stringify(envelope)}\n`, "utf8");
16
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true, mode: 0o700 });
17
+ const temporary = `${absolutePath}.${process.pid}.tmp`;
18
+ await fs.writeFile(temporary, bytes, { mode: 0o600 });
19
+ await fs.rename(temporary, absolutePath);
20
+ return {
21
+ delivered: true,
22
+ deliveredTo: `file:${absolutePath}`,
23
+ bytes: bytes.byteLength,
24
+ };
25
+ }
26
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { CliError } from "./errors.js";
5
+ function feedbackPath() {
6
+ return (process.env.RECESS_CLI_FEEDBACK_PATH ??
7
+ path.join(os.homedir(), ".recess-cli", "feedback.jsonl"));
8
+ }
9
+ async function readFeedback() {
10
+ try {
11
+ return (await fs.readFile(feedbackPath(), "utf8"))
12
+ .split("\n")
13
+ .filter(Boolean)
14
+ .flatMap((line) => {
15
+ try {
16
+ return [JSON.parse(line)];
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ });
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT")
25
+ return [];
26
+ throw error;
27
+ }
28
+ }
29
+ export async function listFeedback(limit = 20) {
30
+ const entries = (await readFeedback()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
31
+ return {
32
+ entries: entries.slice(0, limit),
33
+ truncated: entries.length > limit,
34
+ };
35
+ }
36
+ export async function submitFeedback(entry) {
37
+ const existing = (await readFeedback()).find((item) => item.id === entry.id);
38
+ if (existing)
39
+ return existing;
40
+ const endpoint = process.env.RECESS_CLI_FEEDBACK_ENDPOINT;
41
+ let upstreamStatus;
42
+ if (endpoint) {
43
+ const response = await fetch(endpoint, {
44
+ method: "POST",
45
+ headers: {
46
+ "content-type": "application/json",
47
+ "idempotency-key": entry.id,
48
+ },
49
+ body: JSON.stringify(entry),
50
+ signal: AbortSignal.timeout(10_000),
51
+ });
52
+ upstreamStatus = response.status;
53
+ if (!response.ok) {
54
+ throw new CliError("feedback_delivery_failed", `Feedback endpoint returned HTTP ${response.status}. Retry the unchanged command with the same operation key.`);
55
+ }
56
+ }
57
+ const saved = { ...entry, ...(upstreamStatus ? { upstreamStatus } : {}) };
58
+ const file = feedbackPath();
59
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
60
+ await fs.appendFile(file, `${JSON.stringify(saved)}\n`, { mode: 0o600 });
61
+ await fs.chmod(file, 0o600);
62
+ return saved;
63
+ }
64
+ //# sourceMappingURL=feedback.js.map
package/dist/http.js ADDED
@@ -0,0 +1,25 @@
1
+ import { CliError } from "./errors.js";
2
+ export const RECESS_CLIENT_HEADER = "x-recess-client";
3
+ export const RECESS_CLIENT_CLI = "cli";
4
+ export const RECESS_CLIENT_CLI_UI = "cli-ui";
5
+ export const RECESS_REASON_HEADER = "x-recess-reason";
6
+ export function requireCliRequestReason(value) {
7
+ const reason = value?.trim();
8
+ if (!reason) {
9
+ throw new CliError("invalid_arguments", "Missing required --reason.");
10
+ }
11
+ if (reason.length > 1024) {
12
+ throw new CliError("invalid_arguments", "--reason must be 1024 characters or fewer.");
13
+ }
14
+ return reason;
15
+ }
16
+ export function markCliRequest(headers, reason, client = RECESS_CLIENT_CLI) {
17
+ headers.set(RECESS_CLIENT_HEADER, client);
18
+ if (reason)
19
+ headers.set(RECESS_REASON_HEADER, reason);
20
+ return headers;
21
+ }
22
+ export function cliRequestHeaders(init, reason, client = RECESS_CLIENT_CLI) {
23
+ return markCliRequest(new Headers(init), reason, client);
24
+ }
25
+ //# sourceMappingURL=http.js.map
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { createInterface } from "node:readline/promises";
3
3
  import { parseArgs } from "./args.js";
4
4
  import { runCommand } from "./cli.js";
5
+ import { deliverJson } from "./delivery.js";
5
6
  import { CliError } from "./errors.js";
6
7
  async function withInteractiveAdmissionStage(args) {
7
8
  const parsed = parseArgs(args);
@@ -38,7 +39,8 @@ async function withInteractiveAdmissionStage(args) {
38
39
  }
39
40
  }
40
41
  const argv = await withInteractiveAdmissionStage(process.argv.slice(2));
41
- const json = argv.includes("--json");
42
+ const parsedArgv = parseArgs(argv);
43
+ const json = parsedArgv.flags.has("json");
42
44
  // The interactive console owns the terminal, so it runs before the JSON
43
45
  // envelope machinery rather than through it.
44
46
  if (argv[0] === "ui") {
@@ -57,11 +59,21 @@ if (argv[0] === "ui") {
57
59
  }
58
60
  try {
59
61
  const data = await runCommand(argv);
60
- if (typeof data === "object" && data && "help" in data && !json) {
62
+ const destination = parsedArgv.flags.get("deliver");
63
+ if (destination === true) {
64
+ throw new CliError("invalid_delivery", "--deliver requires a value.");
65
+ }
66
+ const envelope = { ok: true, data };
67
+ const delivery = await deliverJson(destination, envelope);
68
+ if (delivery.delivered) {
69
+ const acknowledgement = { ok: true, data: delivery };
70
+ process.stdout.write(`${json ? JSON.stringify(acknowledgement) : JSON.stringify(acknowledgement.data, null, 2)}\n`);
71
+ }
72
+ else if (typeof data === "object" && data && "help" in data && !json) {
61
73
  process.stdout.write(`${String(data.help)}\n`);
62
74
  }
63
75
  else if (json) {
64
- process.stdout.write(`${JSON.stringify({ ok: true, data })}\n`);
76
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
65
77
  }
66
78
  else {
67
79
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
package/dist/jobs.js ADDED
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function jobsPath() {
5
+ return (process.env.RECESS_CLI_JOBS_PATH ??
6
+ path.join(os.homedir(), ".recess-cli", "jobs.jsonl"));
7
+ }
8
+ async function readEvents() {
9
+ try {
10
+ return (await fs.readFile(jobsPath(), "utf8"))
11
+ .split("\n")
12
+ .filter(Boolean)
13
+ .flatMap((line) => {
14
+ try {
15
+ return [JSON.parse(line)];
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ });
21
+ }
22
+ catch (error) {
23
+ if (error.code === "ENOENT")
24
+ return [];
25
+ throw error;
26
+ }
27
+ }
28
+ export async function appendJobEvent(event) {
29
+ const file = jobsPath();
30
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
31
+ let stored = event;
32
+ const serialized = JSON.stringify(stored);
33
+ if (serialized.length > 64 * 1024 && event.result !== undefined) {
34
+ stored = {
35
+ ...event,
36
+ result: {
37
+ omitted: true,
38
+ reason: "Result exceeded the 64 KiB local job-ledger limit.",
39
+ bytes: Buffer.byteLength(serialized),
40
+ },
41
+ };
42
+ }
43
+ await fs.appendFile(file, `${JSON.stringify(stored)}\n`, { mode: 0o600 });
44
+ await fs.chmod(file, 0o600);
45
+ }
46
+ function foldJobs(events) {
47
+ const jobs = new Map();
48
+ for (const event of events) {
49
+ const prior = jobs.get(event.jobId);
50
+ jobs.set(event.jobId, { ...event, events: (prior?.events ?? 0) + 1 });
51
+ }
52
+ return Array.from(jobs.values()).sort((left, right) => right.timestamp.localeCompare(left.timestamp));
53
+ }
54
+ export async function listJobs(limit = 20) {
55
+ const jobs = foldJobs(await readEvents());
56
+ return {
57
+ jobs: jobs.slice(0, limit),
58
+ truncated: jobs.length > limit,
59
+ total: jobs.length,
60
+ };
61
+ }
62
+ export async function getJob(jobId) {
63
+ const history = (await readEvents()).filter((event) => event.jobId === jobId);
64
+ return { job: foldJobs(history)[0] ?? null, history };
65
+ }
66
+ export async function pruneJobs(olderThanDays) {
67
+ const events = await readEvents();
68
+ const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
69
+ const kept = events.filter((event) => Date.parse(event.timestamp) >= cutoff);
70
+ const file = jobsPath();
71
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
72
+ const temporary = `${file}.${process.pid}.tmp`;
73
+ await fs.writeFile(temporary, kept.length > 0
74
+ ? `${kept.map((event) => JSON.stringify(event)).join("\n")}\n`
75
+ : "", { mode: 0o600 });
76
+ await fs.rename(temporary, file);
77
+ await fs.chmod(file, 0o600);
78
+ return { removed: events.length - kept.length, remaining: kept.length };
79
+ }
80
+ //# sourceMappingURL=jobs.js.map