@opsee/cli 0.11.9
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 +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { accessSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
7
|
+
import { authManager } from "@opsee/mcp-server/src/auth/manager.js";
|
|
8
|
+
import { startLoginFlow } from "@opsee/mcp-server/src/auth/login.js";
|
|
9
|
+
import { createClients, type ApiClients } from "@opsee/mcp-server/src/client/api.js";
|
|
10
|
+
import { parseCommand, usage } from "./args.js";
|
|
11
|
+
import { runAccountAdd, runAccountList, runAccountRemove, runAccountResume, runAccountSet, type AccountDeps } from "./commands/account.js";
|
|
12
|
+
import { runInitiativeCreate, runInitiativeList, runInitiativeMemory, runInitiativeNote, runInitiativeShow, type InitiativeDeps } from "./commands/initiative.js";
|
|
13
|
+
import type { CommandDeps } from "./commands/deps.js";
|
|
14
|
+
import { runForemanDebugReady, runForemanDebugServe, runForemanDebugTurn } from "./commands/foreman-debug.js";
|
|
15
|
+
import { currentRepoRoot, runForemanRun, type ForemanLocal } from "./commands/foreman.js";
|
|
16
|
+
import { runForemanPlan } from "./commands/foreman-plan.js";
|
|
17
|
+
import { runForemanService } from "./commands/foreman-service.js";
|
|
18
|
+
import { runForemanAttach, runForemanCancel, runForemanPause, runForemanRelease, runForemanResume, type ForemanControlDeps } from "./commands/foreman-control.js";
|
|
19
|
+
import { runForemanUp } from "./commands/foreman-up.js";
|
|
20
|
+
import { runForemanLogs, runForemanReview, runForemanStatus } from "./commands/foreman-views.js";
|
|
21
|
+
import { ClaudeWorkerAdapter } from "./foreman/claude-worker-adapter.js";
|
|
22
|
+
import { CodexWorkerAdapter } from "./foreman/codex-worker-adapter.js";
|
|
23
|
+
import type { Account } from "./foreman/account.js";
|
|
24
|
+
import { runLogin } from "./commands/login.js";
|
|
25
|
+
import { runWhoami, NOT_LOGGED_IN } from "./commands/whoami.js";
|
|
26
|
+
import { runInit } from "./commands/init.js";
|
|
27
|
+
import { accountsFilePath, FileAccountStore } from "./foreman/account-store.js";
|
|
28
|
+
import { ProcessTable } from "./foreman/core/process-table.js";
|
|
29
|
+
import { ForemanError } from "./foreman/core/run.js";
|
|
30
|
+
import { TranscriptStore } from "./foreman/core/transcripts.js";
|
|
31
|
+
import { hostIsLaptop } from "./foreman/host.js";
|
|
32
|
+
import { daemonPidPath, foremanLocalDir, processTablePath, transcriptsDir } from "./foreman/local-dir.js";
|
|
33
|
+
import type { ConfigDirFs } from "./foreman/account.js";
|
|
34
|
+
import { OpseeTrackerAdapter } from "./foreman/opsee-tracker-adapter.js";
|
|
35
|
+
import type { Vendor } from "./foreman/vendor.js";
|
|
36
|
+
import type { WorkerAdapter } from "./foreman/worker-adapter.js";
|
|
37
|
+
import { askRecipe, chooseProject } from "./init/prompt.js";
|
|
38
|
+
import { DEFAULT_MCP_URL, SKILLS_DIR, TRACKER_TEMPLATE_PATH } from "./init/paths.js";
|
|
39
|
+
|
|
40
|
+
export interface CliIo {
|
|
41
|
+
log: (line: string) => void;
|
|
42
|
+
error: (line: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the CLI sends as X-Opsee-Client: the backend treats it, like `mcp`, as an automation
|
|
46
|
+
* client acting with a user's token. */
|
|
47
|
+
export const CLI_CLIENT_MARKER = "cli";
|
|
48
|
+
|
|
49
|
+
let cachedClients: ApiClients | null = null;
|
|
50
|
+
function clients(): ApiClients {
|
|
51
|
+
if (!cachedClients) cachedClients = createClients({ marker: CLI_CLIENT_MARKER });
|
|
52
|
+
return cachedClients;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function describeError(error: unknown): string {
|
|
56
|
+
if (error instanceof ConnectError) {
|
|
57
|
+
if (error.code === Code.Unauthenticated) return NOT_LOGGED_IN;
|
|
58
|
+
if (error.code === Code.Unavailable) {
|
|
59
|
+
return `Could not reach Opsee API at ${authManager.getServerUrl()}. Check your connection and OPSEE_API_URL. (${error.rawMessage})`;
|
|
60
|
+
}
|
|
61
|
+
return `[${Code[error.code]}] ${error.rawMessage}`;
|
|
62
|
+
}
|
|
63
|
+
return error instanceof Error ? error.message : String(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Worker Adapter for each vendor an Account can name (ADR-0001). */
|
|
67
|
+
const WORKER_ADAPTERS: Readonly<Record<Vendor, (options: { onLine?: (line: string) => void }) => WorkerAdapter>> = {
|
|
68
|
+
claude: (options) => new ClaudeWorkerAdapter(options),
|
|
69
|
+
codex: (options) => new CodexWorkerAdapter(options),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The machine-local record (ADR-0009), opened once per command that runs or serves a Run. */
|
|
73
|
+
function foremanLocal(): ForemanLocal {
|
|
74
|
+
return { table: ProcessTable.open(processTablePath()), transcripts: new TranscriptStore(transcriptsDir()), pidFile: daemonPidPath() };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The real file system, narrowed to the two calls Account registration may make. */
|
|
78
|
+
const configDirFs: ConfigDirFs = { statSync, accessSync };
|
|
79
|
+
|
|
80
|
+
/** The CLI entry a service unit runs: bin/opsee.js beside src/, absolute (story 16). */
|
|
81
|
+
const CLI_BIN = fileURLToPath(new URL("../bin/opsee.js", import.meta.url));
|
|
82
|
+
|
|
83
|
+
/** One command's stdout, or a rejection when it is missing or fails; for the host probe. */
|
|
84
|
+
function execOutput(file: string, args: string[]): Promise<string> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
execFile(file, args, { timeout: 5_000 }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One service-manager command's exit code and combined output; never a shell. */
|
|
91
|
+
function execResult(file: string, args: string[]): Promise<{ code: number; output: string }> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
execFile(file, args, { timeout: 30_000 }, (error, stdout, stderr) => {
|
|
94
|
+
// A missing binary has a string code (ENOENT); an exit status a number.
|
|
95
|
+
const code = error ? (typeof error.code === "number" ? error.code : 1) : 0;
|
|
96
|
+
resolve({ code, output: `${stdout}${stderr}` });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether this machine has an internal battery (story 17), through the real commands and sysfs. */
|
|
102
|
+
const realHost = { platform: process.platform, isLaptop: () => hostIsLaptop({ platform: process.platform, exec: execOutput, readdir: (dir) => readdirSync(dir), readFile: (path) => readFileSync(path, "utf8") }) };
|
|
103
|
+
|
|
104
|
+
/** The node binary a service unit runs: this process's, which must be node itself. Under another
|
|
105
|
+
* runtime (bun, a wrapper) the unit would run that instead, with launchd's bare environment and no
|
|
106
|
+
* shell to find node. */
|
|
107
|
+
function serviceNode(): string {
|
|
108
|
+
const node = process.execPath;
|
|
109
|
+
if (!/^node(\.exe)?$/.test(basename(node))) {
|
|
110
|
+
throw new ForemanError(`foreman service must run under node so the unit can run the same binary; this is ${node}. Run it as: node bin/opsee.js foreman service ...`);
|
|
111
|
+
}
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Ctrl-C as a promise, for the two views that keep reading until told to stop (`foreman status
|
|
116
|
+
* --watch`, a followed `foreman logs`). `on`, not `once`, like the daemon's: the listener keeps
|
|
117
|
+
* the default handler from ending the process mid-repaint, and the command returns through its own
|
|
118
|
+
* loop instead. Neither view owns a Worker, so there is nothing to stop but the reading. */
|
|
119
|
+
function untilInterrupted(): Promise<void> {
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
process.on("SIGINT", () => resolve());
|
|
122
|
+
process.on("SIGTERM", () => resolve());
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The Worker Adapter for an Account's vendor, from the table above. */
|
|
127
|
+
function workerAdapterFor(account: Account, options: { onLine?: (line: string) => void } = {}): WorkerAdapter {
|
|
128
|
+
const make = WORKER_ADAPTERS[account.vendor];
|
|
129
|
+
if (!make) throw new Error(`No Worker Adapter for vendor "${account.vendor}"`);
|
|
130
|
+
return make(options);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What login and whoami need: mcp's auth module and generated clients (ADR-0010). */
|
|
134
|
+
function apiDeps(io: CliIo): CommandDeps {
|
|
135
|
+
return {
|
|
136
|
+
isAuthenticated: () => authManager.isAuthenticated(),
|
|
137
|
+
users: clients().users,
|
|
138
|
+
out: io.log,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the Run controls need: the Tracker for the Run Record, the Accounts and adapters for the
|
|
143
|
+
* attended turn, and the machine-local record they steer through. */
|
|
144
|
+
function controlDeps(io: CliIo): ForemanControlDeps {
|
|
145
|
+
return {
|
|
146
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
147
|
+
tracker: new OpseeTrackerAdapter(clients()),
|
|
148
|
+
adapterFor: (account) => workerAdapterFor(account),
|
|
149
|
+
out: io.log,
|
|
150
|
+
local: foremanLocal(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What the Foreman Account commands need: local files only, no client and no login. */
|
|
155
|
+
/** Every Initiative command needs the same two things: the API and somewhere to print. */
|
|
156
|
+
function initiativeDeps(io: CliIo): InitiativeDeps {
|
|
157
|
+
return { clients: clients(), out: io.log };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function accountDeps(io: CliIo): AccountDeps {
|
|
161
|
+
return {
|
|
162
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
163
|
+
fs: configDirFs,
|
|
164
|
+
env: process.env,
|
|
165
|
+
out: io.log,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Runs one invocation of `opsee` and returns the exit code. One credential for the MCP server
|
|
170
|
+
* and the CLI: both read and write through mcp's auth module, and both talk to the backend through
|
|
171
|
+
* mcp's generated Connect-RPC clients (ADR-0010). */
|
|
172
|
+
export async function main(argv: string[], io: CliIo = console): Promise<number> {
|
|
173
|
+
const command = parseCommand(argv);
|
|
174
|
+
if (command.kind === "help") {
|
|
175
|
+
io.log(usage());
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
if (command.kind === "unknown") {
|
|
179
|
+
io.error(`Unknown command: ${command.input}\n`);
|
|
180
|
+
io.error(usage());
|
|
181
|
+
return 2;
|
|
182
|
+
}
|
|
183
|
+
if (command.kind === "usage-error") {
|
|
184
|
+
io.error(`${command.message}\n`);
|
|
185
|
+
io.error(usage());
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
switch (command.kind) {
|
|
191
|
+
case "login":
|
|
192
|
+
return await runLogin({ ...apiDeps(io), startLoginFlow });
|
|
193
|
+
case "whoami":
|
|
194
|
+
return await runWhoami(apiDeps(io));
|
|
195
|
+
case "init": {
|
|
196
|
+
const { projects, labels, boards, boardColumns, taskTypes, taskPriorities } = clients();
|
|
197
|
+
return await runInit({
|
|
198
|
+
isAuthenticated: () => authManager.isAuthenticated(),
|
|
199
|
+
out: io.log,
|
|
200
|
+
api: { projects, labels, boards, boardColumns, taskTypes, taskPriorities },
|
|
201
|
+
root: process.cwd(),
|
|
202
|
+
projectKey: command.projectKey,
|
|
203
|
+
choose: chooseProject,
|
|
204
|
+
mcpUrl: process.env.OPSEE_MCP_URL || DEFAULT_MCP_URL,
|
|
205
|
+
skillsDir: SKILLS_DIR,
|
|
206
|
+
trackerTemplate: readFileSync(TRACKER_TEMPLATE_PATH, "utf8"),
|
|
207
|
+
recipeFlags: command.recipe,
|
|
208
|
+
askRecipe,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
case "foreman-account-add":
|
|
212
|
+
return await runAccountAdd(accountDeps(io), command.args);
|
|
213
|
+
case "foreman-account-list":
|
|
214
|
+
return await runAccountList(accountDeps(io));
|
|
215
|
+
case "foreman-account-set":
|
|
216
|
+
return await runAccountSet(accountDeps(io), command.args);
|
|
217
|
+
case "foreman-account-remove":
|
|
218
|
+
return await runAccountRemove(accountDeps(io), command.name);
|
|
219
|
+
case "foreman-account-resume":
|
|
220
|
+
return await runAccountResume(accountDeps(io), command.name);
|
|
221
|
+
case "initiative-list":
|
|
222
|
+
return await runInitiativeList(initiativeDeps(io), command.args);
|
|
223
|
+
case "initiative-show":
|
|
224
|
+
return await runInitiativeShow(initiativeDeps(io), command.args);
|
|
225
|
+
case "initiative-create":
|
|
226
|
+
return await runInitiativeCreate(initiativeDeps(io), command.args);
|
|
227
|
+
case "initiative-memory":
|
|
228
|
+
return await runInitiativeMemory(initiativeDeps(io), command.args);
|
|
229
|
+
case "initiative-note":
|
|
230
|
+
return await runInitiativeNote(initiativeDeps(io), command.args);
|
|
231
|
+
case "foreman-up":
|
|
232
|
+
return await runForemanUp({
|
|
233
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
234
|
+
tracker: new OpseeTrackerAdapter(clients()),
|
|
235
|
+
adapterFor: (account) => workerAdapterFor(account),
|
|
236
|
+
repoRoot: currentRepoRoot,
|
|
237
|
+
out: io.log,
|
|
238
|
+
local: foremanLocal(),
|
|
239
|
+
host: realHost,
|
|
240
|
+
// `on`, not `once`, for the same reason as debug serve below; the Worker hooks see this
|
|
241
|
+
// listener and do not re-raise, so the daemon leaves through its own loop.
|
|
242
|
+
untilStop: new Promise((resolve) => {
|
|
243
|
+
process.on("SIGINT", () => resolve());
|
|
244
|
+
process.on("SIGTERM", () => resolve());
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
case "foreman-run":
|
|
248
|
+
return await runForemanRun(
|
|
249
|
+
{
|
|
250
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
251
|
+
tracker: new OpseeTrackerAdapter(clients()),
|
|
252
|
+
adapterFor: (account) => workerAdapterFor(account),
|
|
253
|
+
repoRoot: currentRepoRoot,
|
|
254
|
+
out: io.log,
|
|
255
|
+
local: foremanLocal(),
|
|
256
|
+
},
|
|
257
|
+
command.args,
|
|
258
|
+
);
|
|
259
|
+
case "foreman-plan":
|
|
260
|
+
return await runForemanPlan(
|
|
261
|
+
{
|
|
262
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
263
|
+
tracker: new OpseeTrackerAdapter(clients()),
|
|
264
|
+
adapterFor: (account) => workerAdapterFor(account),
|
|
265
|
+
repoRoot: currentRepoRoot,
|
|
266
|
+
out: io.log,
|
|
267
|
+
},
|
|
268
|
+
command.args,
|
|
269
|
+
);
|
|
270
|
+
case "foreman-service":
|
|
271
|
+
return await runForemanService(
|
|
272
|
+
{
|
|
273
|
+
platform: process.platform,
|
|
274
|
+
home: homedir(),
|
|
275
|
+
uid: process.getuid?.(),
|
|
276
|
+
node: serviceNode(),
|
|
277
|
+
bin: CLI_BIN,
|
|
278
|
+
repoRoot: currentRepoRoot,
|
|
279
|
+
localDir: foremanLocalDir(),
|
|
280
|
+
pidFile: daemonPidPath(),
|
|
281
|
+
env: process.env,
|
|
282
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
283
|
+
exec: execResult,
|
|
284
|
+
out: io.log,
|
|
285
|
+
},
|
|
286
|
+
command.args,
|
|
287
|
+
);
|
|
288
|
+
// `untilInterrupted` is passed as the function it is, not called here: it installs SIGINT and
|
|
289
|
+
// SIGTERM handlers, and installing those for a one-shot `status` or a `logs --no-follow`
|
|
290
|
+
// that never awaits the promise would leave Ctrl-C doing nothing at all on a command that
|
|
291
|
+
// should simply die. Each command calls it only on the branch that waits.
|
|
292
|
+
case "foreman-status":
|
|
293
|
+
return await runForemanStatus(
|
|
294
|
+
{ store: new FileAccountStore(accountsFilePath()), local: foremanLocal(), out: io.log, untilStop: untilInterrupted },
|
|
295
|
+
command.args,
|
|
296
|
+
);
|
|
297
|
+
case "foreman-logs":
|
|
298
|
+
return await runForemanLogs(
|
|
299
|
+
{ local: foremanLocal(), tracker: new OpseeTrackerAdapter(clients()), out: io.log, untilStop: untilInterrupted },
|
|
300
|
+
command.args,
|
|
301
|
+
);
|
|
302
|
+
case "foreman-review":
|
|
303
|
+
return await runForemanReview({ tracker: new OpseeTrackerAdapter(clients()), local: foremanLocal(), out: io.log }, command.args);
|
|
304
|
+
case "foreman-attach":
|
|
305
|
+
return await runForemanAttach(controlDeps(io), command.taskId);
|
|
306
|
+
case "foreman-release":
|
|
307
|
+
return await runForemanRelease(controlDeps(io), command.taskId);
|
|
308
|
+
case "foreman-cancel":
|
|
309
|
+
return await runForemanCancel(controlDeps(io), command.taskId);
|
|
310
|
+
case "foreman-pause":
|
|
311
|
+
return await runForemanPause(controlDeps(io), command.initiativeId);
|
|
312
|
+
case "foreman-resume":
|
|
313
|
+
return await runForemanResume(controlDeps(io), command.initiativeId);
|
|
314
|
+
case "foreman-debug-ready":
|
|
315
|
+
return await runForemanDebugReady(
|
|
316
|
+
{ tracker: new OpseeTrackerAdapter(clients()), out: io.log },
|
|
317
|
+
command.initiativeId,
|
|
318
|
+
);
|
|
319
|
+
case "foreman-debug-serve":
|
|
320
|
+
return await runForemanDebugServe(
|
|
321
|
+
// `on`, not `once`: a terminal's Ctrl-C and the bin wrapper's forward both arrive, and
|
|
322
|
+
// a second SIGINT with no listener left would kill the process before the app is stopped.
|
|
323
|
+
{ root: process.cwd(), out: io.log, untilStop: new Promise((resolve) => process.on("SIGINT", () => resolve())) },
|
|
324
|
+
command.port,
|
|
325
|
+
);
|
|
326
|
+
case "foreman-debug-turn":
|
|
327
|
+
return await runForemanDebugTurn(
|
|
328
|
+
{
|
|
329
|
+
store: new FileAccountStore(accountsFilePath()),
|
|
330
|
+
adapterFor: (account, { raw }) => workerAdapterFor(account, { onLine: raw ? io.log : undefined }),
|
|
331
|
+
isDirectory: (path) => statSync(path).isDirectory(),
|
|
332
|
+
out: io.log,
|
|
333
|
+
},
|
|
334
|
+
command.args,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
io.error(describeError(error));
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { AccountAddArgs, AccountSetArgs } from "../args.js";
|
|
2
|
+
import {
|
|
3
|
+
AccountError,
|
|
4
|
+
clearQuarantine,
|
|
5
|
+
describeAccount,
|
|
6
|
+
describeLimits,
|
|
7
|
+
formatAccountTable,
|
|
8
|
+
registerAccount,
|
|
9
|
+
noteResumed,
|
|
10
|
+
removeAccount,
|
|
11
|
+
resumeAccount,
|
|
12
|
+
setAccountLimits,
|
|
13
|
+
type ConfigDirFs,
|
|
14
|
+
} from "../foreman/account.js";
|
|
15
|
+
import type { AccountStore } from "../foreman/account-store.js";
|
|
16
|
+
import { printableOneLine } from "../foreman/core/text.js";
|
|
17
|
+
|
|
18
|
+
/** Account commands are local: no backend, no login. The fs seam is the whole of their file-system
|
|
19
|
+
* reach beyond the store; `env` is only ever asked whether a name is set, never for a value. */
|
|
20
|
+
export interface AccountDeps {
|
|
21
|
+
store: AccountStore;
|
|
22
|
+
fs: ConfigDirFs;
|
|
23
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
24
|
+
out: (line: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns the process exit code. Validation failures throw AccountError for the CLI to report. */
|
|
28
|
+
export async function runAccountAdd(deps: AccountDeps, args: AccountAddArgs): Promise<number> {
|
|
29
|
+
if (args.vendor === undefined) {
|
|
30
|
+
throw new AccountError("--vendor is required");
|
|
31
|
+
}
|
|
32
|
+
const account = registerAccount(deps.store, deps.fs, {
|
|
33
|
+
vendor: args.vendor,
|
|
34
|
+
name: args.name,
|
|
35
|
+
configDir: args.configDir,
|
|
36
|
+
keyRef: args.keyEnv,
|
|
37
|
+
cap: positiveInteger("--cap", args.cap),
|
|
38
|
+
});
|
|
39
|
+
deps.out(`Registered ${describeAccount(account)}`);
|
|
40
|
+
if (account.type === "api_key" && !(account.keyRef in deps.env)) {
|
|
41
|
+
deps.out(`Note: ${account.keyRef} is not set in this shell; a Worker on this Account needs it at launch.`);
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runAccountList(deps: AccountDeps): Promise<number> {
|
|
47
|
+
const accounts = deps.store.load();
|
|
48
|
+
if (accounts.length === 0) {
|
|
49
|
+
deps.out("No Accounts registered. Run `opsee foreman account add` to register one.");
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
for (const line of formatAccountTable(accounts)) deps.out(line);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `opsee foreman account set <name>` (story 27): the Account's Slot cap and per-turn limits.
|
|
57
|
+
* Raising a cap is only ever this command — the Foreman never widens one on its own, so the
|
|
58
|
+
* concurrency a vendor sees on an identity stays something a person chose. */
|
|
59
|
+
export async function runAccountSet(deps: AccountDeps, args: AccountSetArgs): Promise<number> {
|
|
60
|
+
const account = setAccountLimits(deps.store, args.name, {
|
|
61
|
+
cap: positiveInteger("--cap", args.cap),
|
|
62
|
+
maxTurns: positiveInteger("--max-turns", args.maxTurns),
|
|
63
|
+
stallTimeoutMs: positiveInteger("--stall-timeout", args.stallTimeoutMs),
|
|
64
|
+
});
|
|
65
|
+
deps.out(`Account "${account.name}": ${describeLimits(account)}`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A flag's value as digits and nothing else, refused here in the words the model layer uses. It is
|
|
70
|
+
* `Number` this stands in for: `Number("0x10")` is 16, `Number("1e3")` is 1000 and `Number(" 7\\n")`
|
|
71
|
+
* is 7, so `--cap 0x10` would have quietly registered an Account at cap 16 under a message that
|
|
72
|
+
* says positive integer. The range itself (`MAX_CAP`, `MIN_STALL_TIMEOUT_MS`) is the model's. */
|
|
73
|
+
function positiveInteger(flag: string, text: string | undefined): number | undefined {
|
|
74
|
+
if (text === undefined) return undefined;
|
|
75
|
+
if (!/^\d+$/.test(text)) throw new AccountError(`${flag} must be a positive integer`);
|
|
76
|
+
return Number(text);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `opsee foreman account resume <name>` (OPS-288): lifts a quarantine, and a pause with it.
|
|
81
|
+
*
|
|
82
|
+
* A quarantine is the one Account state nothing clears by itself, and registering the Account again
|
|
83
|
+
* is the documented way out — but `account add` refuses a name or a source it already has, so that
|
|
84
|
+
* path is really remove-then-add, which silently drops the Account's cap and per-turn limits. This
|
|
85
|
+
* is the same lift without that cost, for the ordinary case: the operator has just logged the
|
|
86
|
+
* vendor back in and the credential behind the unchanged path is live again.
|
|
87
|
+
*
|
|
88
|
+
* It also clears a pause, which needs no command of its own (resume is time-based) but would be
|
|
89
|
+
* surprising to leave behind on an Account the operator has just asked to come back.
|
|
90
|
+
*/
|
|
91
|
+
export async function runAccountResume(deps: AccountDeps, name: string): Promise<number> {
|
|
92
|
+
const before = deps.store.load().find((a) => a.name === name);
|
|
93
|
+
if (!before) {
|
|
94
|
+
throw new AccountError(`No Account named "${name}"`);
|
|
95
|
+
}
|
|
96
|
+
const account = clearQuarantine(deps.store, name);
|
|
97
|
+
if (!account) {
|
|
98
|
+
throw new AccountError(`No Account named "${name}"`);
|
|
99
|
+
}
|
|
100
|
+
resumeAccount(deps.store, name);
|
|
101
|
+
// Stamped for the Run Record, which this command cannot reach: it has no Initiative, and a Run
|
|
102
|
+
// Record belongs to one. The next Run that builds a Lane on this Account reports the lift with
|
|
103
|
+
// this timestamp (`ResumedState`, core/run.ts `reportResumedAccounts`).
|
|
104
|
+
const wasPaused = before.paused !== null && before.paused !== undefined;
|
|
105
|
+
const from = before.quarantined ? (wasPaused ? "both" : "quarantined") : "paused";
|
|
106
|
+
noteResumed(deps.store, name, new Date(), from);
|
|
107
|
+
// Said plainly either way: "nothing was wrong with it" is an answer the operator came for just as
|
|
108
|
+
// much as the lift, and a silent success would read as the quarantine having been the problem.
|
|
109
|
+
deps.out(
|
|
110
|
+
before.quarantined
|
|
111
|
+
? `Account "${account.name}" is no longer quarantined (it was, since ${before.quarantined.at}: ${printableOneLine(before.quarantined.reason)}). The next tick may dispatch on it.`
|
|
112
|
+
: `Account "${account.name}" was not quarantined; any pause on it is cleared.`,
|
|
113
|
+
);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function runAccountRemove(deps: AccountDeps, name: string): Promise<number> {
|
|
118
|
+
const removed = removeAccount(deps.store, name);
|
|
119
|
+
deps.out(`Removed Account "${removed.name}"`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IdentityClient } from "../identity.js";
|
|
2
|
+
|
|
3
|
+
/** What the backend-facing commands (login, whoami) need from the outside world; cli.ts builds it
|
|
4
|
+
* from mcp's auth module and generated clients, tests hand in fakes. Foreman Account commands are
|
|
5
|
+
* local and take AccountDeps instead. */
|
|
6
|
+
export interface CommandDeps {
|
|
7
|
+
/** Whether a usable token is stored (mcp's AuthManager.isAuthenticated). */
|
|
8
|
+
isAuthenticated: () => boolean;
|
|
9
|
+
users: IdentityClient;
|
|
10
|
+
out: (line: string) => void;
|
|
11
|
+
}
|