@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.
Files changed (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `opsee initiative` (phase 1): list, show, create, and the memory log.
3
+ *
4
+ * Thin on purpose. Every one of these calls the same RPC the matching MCP tool calls and prints the
5
+ * same formatter's output (`@opsee/mcp-server/src/utils/format/initiative.js`), so the two surfaces
6
+ * cannot drift about what an Initiative looks like — a reader who has seen one has seen the other.
7
+ * Nothing here decides anything the MCP tools decide differently, and nothing formats an Initiative
8
+ * of its own.
9
+ *
10
+ * What the commands add over the tools is the two things a terminal has and an agent does not: a
11
+ * project resolved from `--project` or from the account having only one (`pickProject`, shared with
12
+ * `opsee init`), and a core idea read from a file or stdin, because a markdown body does not fit in
13
+ * argv.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import type { ApiClients } from "@opsee/mcp-server/src/client/api.js";
17
+ import { formatInitiative, formatInitiativeContext, formatInitiativeList, formatInitiativeMemoryEntry, formatInitiativeMemoryList } from "@opsee/mcp-server/src/utils/format/initiative.js";
18
+ import type { InitiativeCreateArgs, InitiativeListArgs, InitiativeMemoryArgs, InitiativeNoteArgs, InitiativeShowArgs } from "../args.js";
19
+ import { pickProject, type ProjectChoice } from "../init/project.js";
20
+
21
+ /** The memory kinds the Initiative log takes (api.v1: decision | outcome | learning | blocker |
22
+ * context). Checked here rather than left to the backend so a typo answers in the CLI's own words
23
+ * with the list, instead of as an InvalidArgument from a service the user never called by name. */
24
+ export const MEMORY_KINDS = ["decision", "outcome", "learning", "blocker", "context"] as const;
25
+
26
+ /**
27
+ * What `note` writes when no `--kind` is given.
28
+ *
29
+ * `opsee_add_initiative_memory` requires the kind, and is right to: an agent logging as it goes
30
+ * should be deliberate about whether this is a decision or a blocker. A person typing a note at a
31
+ * terminal should not have to be, and `context` is the kind that claims the least — it is a note,
32
+ * not a decision anyone else should read as settled.
33
+ */
34
+ export const DEFAULT_NOTE_KIND = "context";
35
+
36
+ /** The Initiative statuses the API takes. Same reasoning as `MEMORY_KINDS`. */
37
+ export const INITIATIVE_STATUSES = ["draft", "active", "paused", "done", "archived"] as const;
38
+
39
+ /** A refusal the user can fix, reported as a plain message rather than a stack; `cli.ts` prints it
40
+ * and exits non-zero, the way `AccountError` is handled. */
41
+ export class InitiativeError extends Error {}
42
+
43
+ export interface InitiativeDeps {
44
+ clients: ApiClients;
45
+ out: (line: string) => void;
46
+ /** Reads a core idea file. Injected so the tests never touch a real path. */
47
+ readFile?: (path: string) => string;
48
+ /** Reads the core idea from stdin, for `--core-idea-file -`. */
49
+ readStdin?: () => Promise<string>;
50
+ }
51
+
52
+ /** How many memory entries `show --context` and `memory` ask for when nothing says otherwise; the
53
+ * service has its own default, and this is only the flag's shape. */
54
+ const DEFAULT_MEMORY_LIMIT = 0;
55
+
56
+ function positiveInteger(flag: string, text: string | undefined): number | undefined {
57
+ if (text === undefined) return undefined;
58
+ // `Number` would take "0x10" as 16 and " 7\n" as 7, under a message that says whole number.
59
+ if (!/^\d+$/.test(text)) throw new InitiativeError(`${flag} must be a whole number`);
60
+ return Number(text);
61
+ }
62
+
63
+ function oneOf<T extends readonly string[]>(flag: string, value: string | undefined, allowed: T): T[number] | undefined {
64
+ if (value === undefined) return undefined;
65
+ if (!(allowed as readonly string[]).includes(value)) {
66
+ throw new InitiativeError(`${flag} must be one of: ${allowed.join(", ")} (got "${value}")`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * The project a command runs against: the `--project` key, or the account's only project.
73
+ *
74
+ * `pickProject` is `opsee init`'s, with no way to choose interactively — these commands are as
75
+ * likely to run in a script as at a keyboard, and a prompt in a pipe is a hang. With several
76
+ * projects and no `--project`, it answers with the message naming the keys.
77
+ */
78
+ async function resolveProject(deps: InitiativeDeps, key: string | undefined): Promise<ProjectChoice> {
79
+ const res = await deps.clients.projects.getProjects({ pagination: { page: 1, pageSize: 200 } });
80
+ const projects: ProjectChoice[] = res.projects.map((p) => ({ id: p.id, key: p.key, name: p.name }));
81
+ const picked = await pickProject(projects, key, async () => null);
82
+ if (typeof picked === "string") throw new InitiativeError(picked);
83
+ return picked;
84
+ }
85
+
86
+ export async function runInitiativeList(deps: InitiativeDeps, args: InitiativeListArgs): Promise<number> {
87
+ const project = await resolveProject(deps, args.project);
88
+ const res = await deps.clients.initiatives.getInitiatives({
89
+ projectId: project.id,
90
+ pagination: { page: 1, pageSize: 200 },
91
+ filterOptions: args.search ? { search: args.search } : undefined,
92
+ });
93
+ deps.out(formatInitiativeList(res.initiatives));
94
+ return 0;
95
+ }
96
+
97
+ export async function runInitiativeShow(deps: InitiativeDeps, args: InitiativeShowArgs): Promise<number> {
98
+ if (args.context) {
99
+ const memoryLimit = positiveInteger("--memory", args.memory) ?? DEFAULT_MEMORY_LIMIT;
100
+ const res = await deps.clients.initiatives.getInitiativeContext({ initiativeId: args.initiativeId, memoryLimit });
101
+ if (!res.initiative) throw new InitiativeError(`No Initiative ${args.initiativeId}`);
102
+ // The tool's own renderer, working agreement and all: an operator reading this and an agent
103
+ // that called `opsee_get_initiative_context` are looking at the same page.
104
+ deps.out(formatInitiativeContext(res.initiative, res.graph, res.memoryEntries, res.pullRequests, res.comments));
105
+ return 0;
106
+ }
107
+ // `--memory` says nothing without `--context`; refused rather than ignored, since a flag that
108
+ // silently does nothing is a flag the user believes worked.
109
+ if (args.memory !== undefined) throw new InitiativeError("--memory only applies with --context; without it, show prints the Initiative's details alone. For the log on its own: opsee initiative memory <initiativeId>");
110
+ const res = await deps.clients.initiatives.getInitiative({ id: args.initiativeId });
111
+ if (!res.initiative) throw new InitiativeError(`No Initiative ${args.initiativeId}`);
112
+ deps.out(formatInitiative(res.initiative));
113
+ return 0;
114
+ }
115
+
116
+ /** The core idea's body: a file, or stdin when the path is `-`. Absent when no flag was given. */
117
+ async function coreIdeaOf(deps: InitiativeDeps, path: string | undefined): Promise<string | undefined> {
118
+ if (path === undefined) return undefined;
119
+ if (path === "-") {
120
+ const read = deps.readStdin ?? readStdin;
121
+ const body = await read();
122
+ if (body.trim() === "") throw new InitiativeError("The core idea read from stdin is empty");
123
+ return body;
124
+ }
125
+ try {
126
+ const body = (deps.readFile ?? ((p: string) => readFileSync(p, "utf8")))(path);
127
+ if (body.trim() === "") throw new InitiativeError(`The core idea file is empty: ${path}`);
128
+ return body;
129
+ } catch (error) {
130
+ if (error instanceof InitiativeError) throw error;
131
+ throw new InitiativeError(`Could not read the core idea file ${path}: ${error instanceof Error ? error.message : String(error)}`);
132
+ }
133
+ }
134
+
135
+ function readStdin(): Promise<string> {
136
+ return new Promise((resolve, reject) => {
137
+ let body = "";
138
+ process.stdin.setEncoding("utf8");
139
+ process.stdin.on("data", (chunk) => (body += chunk));
140
+ process.stdin.on("end", () => resolve(body));
141
+ process.stdin.on("error", reject);
142
+ });
143
+ }
144
+
145
+ export async function runInitiativeCreate(deps: InitiativeDeps, args: InitiativeCreateArgs): Promise<number> {
146
+ const status = oneOf("--status", args.status, INITIATIVE_STATUSES) ?? "draft";
147
+ // Before the project is resolved: a bad flag should be refused without a round trip.
148
+ const coreIdea = await coreIdeaOf(deps, args.coreIdeaFile);
149
+ const project = await resolveProject(deps, args.project);
150
+ const res = await deps.clients.initiatives.addInitiative({
151
+ projectId: project.id,
152
+ title: args.title!,
153
+ summary: args.summary,
154
+ status,
155
+ anchorType: "none",
156
+ coreIdea,
157
+ // Markdown, as the MCP tool sends it: the UI parses it on read.
158
+ coreIdeaContentType: "text",
159
+ });
160
+ if (!res.initiative) throw new InitiativeError("The backend created no Initiative and reported no error. Try again; if it persists, say so to the Opsee team.");
161
+ deps.out(`Initiative created in ${project.key}:`);
162
+ deps.out(formatInitiative(res.initiative));
163
+ return 0;
164
+ }
165
+
166
+ export async function runInitiativeMemory(deps: InitiativeDeps, args: InitiativeMemoryArgs): Promise<number> {
167
+ const kind = oneOf("--kind", args.kind, MEMORY_KINDS);
168
+ const limit = positiveInteger("--limit", args.limit);
169
+ const res = await deps.clients.initiatives.getInitiativeMemory({
170
+ initiativeId: args.initiativeId,
171
+ pagination: { page: 1, pageSize: limit && limit > 0 ? limit : 200 },
172
+ // `kinds` is a list on the wire; the flag takes one, since filtering a log by two kinds at a
173
+ // terminal is a thing to do with `grep`.
174
+ kinds: kind ? [kind] : undefined,
175
+ });
176
+ deps.out(formatInitiativeMemoryList(res.memoryEntries));
177
+ return 0;
178
+ }
179
+
180
+ export async function runInitiativeNote(deps: InitiativeDeps, args: InitiativeNoteArgs): Promise<number> {
181
+ const kind = oneOf("--kind", args.kind, MEMORY_KINDS) ?? DEFAULT_NOTE_KIND;
182
+ const res = await deps.clients.initiatives.addInitiativeMemoryEntry({
183
+ initiativeId: args.initiativeId,
184
+ kind,
185
+ body: args.body,
186
+ contentType: "text",
187
+ sourceType: "none",
188
+ });
189
+ if (!res.memoryEntry) throw new InitiativeError("The backend recorded no memory entry and reported no error. Try again; if it persists, say so to the Opsee team.");
190
+ deps.out(formatInitiativeMemoryEntry(res.memoryEntry));
191
+ return 0;
192
+ }
@@ -0,0 +1,24 @@
1
+ import { fetchIdentity, formatIdentity, identityIfAuthorized } from "../identity.js";
2
+ import type { CommandDeps } from "./deps.js";
3
+
4
+ export interface LoginDeps extends CommandDeps {
5
+ /** The MCP package's browser OAuth flow; resolves once the callback has written credentials. */
6
+ startLoginFlow: () => Promise<void>;
7
+ }
8
+
9
+ /** Returns the process exit code. Already logged in is a no-op that reports the identity; a stored
10
+ * token the backend no longer accepts is treated as logged out and the flow runs again. */
11
+ export async function runLogin(deps: LoginDeps): Promise<number> {
12
+ if (deps.isAuthenticated()) {
13
+ const current = await identityIfAuthorized(deps.users);
14
+ if (current) {
15
+ deps.out(`Already logged in as ${formatIdentity(current)}`);
16
+ return 0;
17
+ }
18
+ }
19
+
20
+ await deps.startLoginFlow();
21
+ const id = await fetchIdentity(deps.users);
22
+ deps.out(`Logged in as ${formatIdentity(id)}`);
23
+ return 0;
24
+ }
@@ -0,0 +1,15 @@
1
+ import { formatIdentity, identityIfAuthorized } from "../identity.js";
2
+ import type { CommandDeps } from "./deps.js";
3
+
4
+ export const NOT_LOGGED_IN = "Not logged in. Run `opsee login` to connect your Opsee account.";
5
+
6
+ /** Returns the process exit code. */
7
+ export async function runWhoami(deps: CommandDeps): Promise<number> {
8
+ const id = deps.isAuthenticated() ? await identityIfAuthorized(deps.users) : null;
9
+ if (!id) {
10
+ deps.out(NOT_LOGGED_IN);
11
+ return 1;
12
+ }
13
+ deps.out(formatIdentity(id));
14
+ return 0;
15
+ }
@@ -0,0 +1,96 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { AccountError, MAX_CAP, type Account } from "./account.js";
5
+
6
+ /** Where registered Accounts live; the file store is the real one, the memory store is for tests. */
7
+ export interface AccountStore {
8
+ load(): Account[];
9
+ save(accounts: Account[]): void;
10
+ }
11
+
12
+ export class MemoryAccountStore implements AccountStore {
13
+ private accounts: Account[] = [];
14
+ load(): Account[] {
15
+ return this.accounts.map((a) => ({ ...a }));
16
+ }
17
+ save(accounts: Account[]): void {
18
+ this.accounts = accounts.map((a) => ({ ...a }));
19
+ }
20
+ }
21
+
22
+ /** Sibling of the credentials file mcp's auth module keeps in `~/.opsee/`, never inside it. */
23
+ export function accountsFilePath(): string {
24
+ return process.env.OPSEE_FOREMAN_ACCOUNTS_PATH || join(homedir(), ".opsee", "foreman-accounts.json");
25
+ }
26
+
27
+ interface AccountsFile {
28
+ version: 1;
29
+ accounts: Account[];
30
+ }
31
+
32
+ /** JSON on disk, owner-readable only, matching how the credentials file beside it is kept. */
33
+ export class FileAccountStore implements AccountStore {
34
+ constructor(private readonly path: string) {}
35
+
36
+ load(): Account[] {
37
+ if (!existsSync(this.path)) return [];
38
+ let parsed: unknown;
39
+ try {
40
+ parsed = JSON.parse(readFileSync(this.path, "utf-8"));
41
+ } catch (error) {
42
+ throw new AccountError(`Accounts file is not valid JSON: ${this.path} (${(error as Error).message})`);
43
+ }
44
+ const file = parsed as Partial<AccountsFile> | null;
45
+ if (!file || file.version !== 1 || !Array.isArray(file.accounts)) {
46
+ throw new AccountError(`Accounts file has an unexpected shape: ${this.path}`);
47
+ }
48
+ // The cap is checked here rather than where it is first used, so a hand-edited file is refused
49
+ // as a message the moment it is read: `new Slots(cap)` (core/scheduler.ts) is the last guard
50
+ // behind this one and it can only throw, which mid-Run reads as a crash rather than a mistake
51
+ // in a file the human wrote.
52
+ for (const account of file.accounts) {
53
+ const cap = (account as Partial<Account> | null)?.cap;
54
+ if (!Number.isInteger(cap) || (cap as number) < 1 || (cap as number) > MAX_CAP) {
55
+ const name = (account as Partial<Account> | null)?.name ?? "(unnamed)";
56
+ throw new AccountError(
57
+ `Account "${name}" in ${this.path} has cap ${JSON.stringify(cap)}; a cap is a whole number from 1 to ${MAX_CAP}. Fix the file, or set it with: opsee foreman account set ${name} --cap <n>`,
58
+ );
59
+ }
60
+ }
61
+ return file.accounts;
62
+ }
63
+
64
+ /**
65
+ * Writes the whole file, through a temp file in the same directory that is renamed over the
66
+ * target. `rename` inside one directory is atomic, so a reader — this Foreman, the next one, or
67
+ * `foreman account list` in another terminal — sees either the old file or the new one, never a
68
+ * half-written one. Without that, a crash or a full disk mid-write leaves JSON that `load`
69
+ * refuses, and every Foreman command then fails until a human repairs the file by hand.
70
+ *
71
+ * It does not make a concurrent read-modify-write safe: the daemon writing a pause and
72
+ * `foreman account set --cap` in another terminal both load, change and save, and the later save
73
+ * wins whole. Fixing that wants a lock file around load-and-save, which is its own change; what
74
+ * this rules out is the failure that needs a human, not the one that loses an edit.
75
+ */
76
+ save(accounts: Account[]): void {
77
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
78
+ const file: AccountsFile = { version: 1, accounts };
79
+ // Same directory, so the rename never crosses a file system; the pid keeps two processes
80
+ // writing at once from using each other's temp file.
81
+ const temp = `${this.path}.${process.pid}.tmp`;
82
+ try {
83
+ writeFileSync(temp, JSON.stringify(file, null, 2) + "\n", { mode: 0o600, encoding: "utf-8" });
84
+ renameSync(temp, this.path);
85
+ } catch (error) {
86
+ // A temp file left behind would be read by nothing, but it would sit beside the real one
87
+ // looking like state; best-effort, since the write's own failure is what matters.
88
+ try {
89
+ rmSync(temp, { force: true });
90
+ } catch {
91
+ /* the write's error is the one worth reporting */
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ }