@lanes-sh/link 0.4.0 → 0.5.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.
Files changed (48) hide show
  1. package/README.md +20 -9
  2. package/instructions/agents/lanes-link-scout.md +14 -3
  3. package/instructions/skills/lanes-link/SKILL.md +80 -3
  4. package/package.json +2 -2
  5. package/src/cli/argv.ts +7 -0
  6. package/src/cli/commands/connect/index.ts +9 -6
  7. package/src/cli/commands/connection.ts +298 -0
  8. package/src/cli/commands/mcp/list.ts +123 -29
  9. package/src/cli/commands/operate/inspect.ts +37 -20
  10. package/src/cli/commands/operate/serve.ts +21 -0
  11. package/src/cli/commands/owner/assets.ts +132 -0
  12. package/src/cli/commands/owner/shared.ts +28 -4
  13. package/src/cli/commands/owner/tasks.ts +194 -0
  14. package/src/cli/commands/owner.ts +9 -4
  15. package/src/cli/config-edit.ts +33 -7
  16. package/src/cli/config-repair.ts +115 -11
  17. package/src/cli/dispatch-owner.ts +49 -8
  18. package/src/cli/lanes.ts +1 -1
  19. package/src/cli/main.ts +26 -3
  20. package/src/cli/provider-marks.ts +1 -1
  21. package/src/cli/runtime/registry.ts +10 -2
  22. package/src/cli/selection.ts +14 -0
  23. package/src/cli/usage.ts +18 -2
  24. package/src/connectivity/mail/attachments.ts +5 -1
  25. package/src/connectivity/mail/index.ts +6 -1
  26. package/src/connectivity/manifest/provider.ts +15 -2
  27. package/src/deployments/deploy.ts +3 -2
  28. package/src/deployments/prepare.ts +1 -1
  29. package/src/deployments/servable.ts +1 -1
  30. package/src/deployments/upload.ts +0 -53
  31. package/src/profile/load.ts +46 -0
  32. package/src/providers/assets/provider.ts +337 -0
  33. package/src/providers/assets/store.ts +167 -0
  34. package/src/providers/bunq/hints.ts +3 -1
  35. package/src/providers/bunq/redact.ts +13 -2
  36. package/src/providers/bunq/specs/bunq.v1.json +20 -1
  37. package/src/providers/bunq/specs/vendor.ts +59 -1
  38. package/src/providers/google/index.ts +1 -1
  39. package/src/providers/google/tasks/index.ts +3 -3
  40. package/src/providers/google/tasks/redact.ts +21 -11
  41. package/src/providers/index.ts +3 -3
  42. package/src/providers/owner.ts +39 -19
  43. package/src/providers/setup/plan.ts +17 -1
  44. package/src/providers/shared/vendor-operations.ts +81 -0
  45. package/src/providers/tasks/provider.ts +370 -0
  46. package/src/providers/tasks/store.ts +248 -0
  47. package/src/server/mcp/build.ts +1 -1
  48. package/src/server/mcp/instructions.ts +67 -8
@@ -1,8 +1,46 @@
1
- import { heading, print, style } from '../../output.ts';
1
+ import { emit, heading, print, style } from '../../output.ts';
2
2
  import { assetState, plannedAssets, readAsset } from './assets.ts';
3
3
  import { HARNESSES, type Harness } from './harnesses.ts';
4
4
  import { exists } from './register.ts';
5
5
 
6
+ /** Why a document this harness can hold is not what we ship, if it is not. */
7
+ export type DocumentState = 'current' | 'stale' | 'missing' | 'unreadable';
8
+
9
+ export interface ListedDocument {
10
+ readonly label: string;
11
+ readonly path: string;
12
+ readonly state: DocumentState;
13
+ /** Only for `unreadable` — what went wrong reading the bundled copy. */
14
+ readonly detail?: string;
15
+ }
16
+
17
+ export interface ListedHarness {
18
+ readonly id: string;
19
+ readonly label: string;
20
+ readonly installed: boolean;
21
+ /** The resolved binary, so a caller can see *which* claude answered. */
22
+ readonly binary: string | null;
23
+ readonly registered: boolean;
24
+ /**
25
+ * Empty when the harness is not installed, matching what the text rendering
26
+ * says: with no binary there is nothing to register the documents against, and
27
+ * reporting them would describe a setup that does not exist.
28
+ */
29
+ readonly documents: readonly ListedDocument[];
30
+ }
31
+
32
+ export interface McpListing {
33
+ readonly name: string;
34
+ readonly scope: string;
35
+ readonly harnesses: readonly ListedHarness[];
36
+ }
37
+
38
+ export interface McpListFlags {
39
+ readonly name?: string | undefined;
40
+ readonly scope?: string | undefined;
41
+ readonly json?: boolean | undefined;
42
+ }
43
+
6
44
  /**
7
45
  * `lanes link mcp list` — where this endpoint is registered, and whether what
8
46
  * each harness has been told about it is still what we ship.
@@ -11,58 +49,114 @@ import { exists } from './register.ts';
11
49
  * different fixes. A registration without the skill is a working endpoint
12
50
  * nobody reaches for; a skill against no registration is a document describing
13
51
  * tools that are not there.
52
+ *
53
+ * The listing is gathered before anything is printed, so `--json` and the text
54
+ * rendering describe the same snapshot rather than two probes taken a moment
55
+ * apart. `--json` exists because this is the one command another program has a
56
+ * reason to read: it is how a UI decides between "add" and "re-add", and the
57
+ * `stale` state is the only signal that a re-run would do something.
14
58
  */
15
- export async function mcpList(options: { name?: string | undefined; scope?: string | undefined }): Promise<void> {
59
+ export async function mcpList(options: McpListFlags): Promise<void> {
16
60
  const name = options.name ?? 'lanes-link';
17
61
  const scope = options.scope ?? 'user';
18
62
 
19
- heading(`Registered as ${style.bold(name)}`);
63
+ const listing = await listRegistrations(name, scope);
64
+
65
+ await emit(options.json, listing, () => render(listing));
66
+ }
67
+
68
+ /** The whole answer, gathered. No printing, so a caller can have it as data. */
69
+ export async function listRegistrations(name: string, scope: string): Promise<McpListing> {
70
+ const harnesses: ListedHarness[] = [];
20
71
 
21
72
  for (const harness of HARNESSES) {
22
73
  const binary = Bun.which(harness.binary);
23
74
 
24
75
  if (!binary) {
25
- print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
76
+ harnesses.push({
77
+ id: harness.id,
78
+ label: harness.label,
79
+ installed: false,
80
+ binary: null,
81
+ registered: false,
82
+ documents: [],
83
+ });
26
84
  continue;
27
85
  }
28
86
 
29
- print(
30
- ` ${harness.label.padEnd(14)} ${
31
- exists(binary, harness, name)
32
- ? style.green('registered')
33
- : style.dim(`not registered — lanes link mcp add ${harness.id}`)
34
- }`,
35
- );
36
-
37
- for (const line of await documentLines(harness, scope)) print(` ${' '.repeat(14)} ${line}`);
87
+ harnesses.push({
88
+ id: harness.id,
89
+ label: harness.label,
90
+ installed: true,
91
+ binary,
92
+ registered: exists(binary, harness, name),
93
+ documents: await documents(harness, scope),
94
+ });
38
95
  }
96
+
97
+ return { name, scope, harnesses };
39
98
  }
40
99
 
41
- /** One line per document this harness can hold, saying whether it is current. */
42
- async function documentLines(harness: Harness, scope: string): Promise<string[]> {
43
- const lines: string[] = [];
100
+ /** One entry per document this harness can hold, saying whether it is current. */
101
+ async function documents(harness: Harness, scope: string): Promise<ListedDocument[]> {
102
+ const listed: ListedDocument[] = [];
44
103
 
45
104
  for (const plan of plannedAssets(harness, scope)) {
46
105
  try {
47
- const state = await assetState(plan, await readAsset(plan.asset));
48
-
49
- lines.push(
50
- state === 'current'
51
- ? style.dim(`${plan.asset.label}: `) + style.green('up to date')
52
- : style.dim(
53
- state === 'stale'
54
- ? `${plan.asset.label}: out of date — lanes link mcp add ${harness.id}`
55
- : `${plan.asset.label}: not installed — lanes link mcp add ${harness.id}`,
56
- ),
57
- );
106
+ listed.push({
107
+ label: plan.asset.label,
108
+ path: plan.path,
109
+ state: await assetState(plan, await readAsset(plan.asset)),
110
+ });
58
111
  } catch (error) {
59
112
  // A checkout without `instructions/` — a container image, say. Worth one
60
113
  // line rather than a thrown error that hides the registration column.
61
- lines.push(style.dim(`${plan.asset.label}: ${message(error)}`));
114
+ listed.push({
115
+ label: plan.asset.label,
116
+ path: plan.path,
117
+ state: 'unreadable',
118
+ detail: message(error),
119
+ });
120
+ }
121
+ }
122
+
123
+ return listed;
124
+ }
125
+
126
+ function render(listing: McpListing): void {
127
+ heading(`Registered as ${style.bold(listing.name)}`);
128
+
129
+ for (const harness of listing.harnesses) {
130
+ if (!harness.installed) {
131
+ print(` ${harness.label.padEnd(14)} ${style.dim('not installed')}`);
132
+ continue;
133
+ }
134
+
135
+ print(
136
+ ` ${harness.label.padEnd(14)} ${
137
+ harness.registered
138
+ ? style.green('registered')
139
+ : style.dim(`not registered — lanes link mcp add ${harness.id}`)
140
+ }`,
141
+ );
142
+
143
+ for (const document of harness.documents) {
144
+ print(` ${' '.repeat(14)} ${documentLine(harness.id, document)}`);
62
145
  }
63
146
  }
147
+ }
64
148
 
65
- return lines;
149
+ function documentLine(id: string, document: ListedDocument): string {
150
+ switch (document.state) {
151
+ case 'current':
152
+ return style.dim(`${document.label}: `) + style.green('up to date');
153
+ case 'stale':
154
+ return style.dim(`${document.label}: out of date — lanes link mcp add ${id}`);
155
+ case 'missing':
156
+ return style.dim(`${document.label}: not installed — lanes link mcp add ${id}`);
157
+ case 'unreadable':
158
+ return style.dim(`${document.label}: ${document.detail}`);
159
+ }
66
160
  }
67
161
 
68
162
  function message(error: unknown): string {
@@ -1,4 +1,5 @@
1
1
  import { credentialRefFor, formatPlan, planIsNoop, planReconcile } from '#registry';
2
+ import { DEFAULT_SURFACES } from '../../config-repair.ts';
2
3
  import { announce, announceProfile, emit, fail, ok, print, warn } from '../../output.ts';
3
4
  import { staleNudge } from '../../release.ts';
4
5
  import { openRuntime, resolveProfileOnly, type GlobalFlags } from '../../runtime.ts';
@@ -180,33 +181,49 @@ export async function doctor(flags: DoctorFlags): Promise<void> {
180
181
  }
181
182
  }
182
183
 
183
- // A profile written before the setup surface existed has no connection for
184
- // it, and `allowedConnections` returns nothing for a provider with no
185
- // connection *before* consulting policy — so the capabilities are simply
186
- // absent, with nothing saying why. An agent then has no way to see what is
187
- // configured and starts guessing at commands.
184
+ // A profile written before the owner layer was default has no connection row
185
+ // for any of it, and `allowedConnections` returns nothing for a provider with
186
+ // no connection *before* consulting policy — so the capabilities are simply
187
+ // absent, with nothing saying why. An agent then has no memory to consult, no
188
+ // list to add to, and no way to see what is configured, and starts guessing.
188
189
  //
189
190
  // Both halves, because either alone is inert: a connection row that no rule
190
191
  // grants serves nothing, and a rule naming a provider with no row is what
191
192
  // `allowedConnections` drops before policy is consulted. Reporting only the
192
- // row left the half-repaired profile reading as healthy while serving
193
- // exactly as little as the untouched one — and both halves are what
194
- // `ensureSetupConnection` writes, so this is the check that says whether it
195
- // has run.
196
- const hasSetupRow = runtime.config.connections.some(
197
- (connection) => connection.provider === 'setup',
198
- );
199
- const grantsSetup = runtime.config.policy.allow.some(
200
- (rule) => rule.capability === '*' || rule.capability === 'setup.*',
201
- );
193
+ // row left the half-repaired profile reading as healthy while serving exactly
194
+ // as little as the untouched one — and both halves are what
195
+ // `ensureOwnerLayer` writes, so this is the check that says whether it ran.
196
+ //
197
+ // A surface the operator has *denied* is not missing, it is off, so a deny
198
+ // covering it is not reported. That is the same rule the repair follows, and
199
+ // reading it here from `policy.deny` rather than asking the repair keeps
200
+ // `doctor` a read.
201
+ const denied = (rule: string): boolean =>
202
+ runtime.config.policy.deny.some(
203
+ (entry) => entry.capability === '*' || entry.capability === rule,
204
+ );
205
+
206
+ const missing = DEFAULT_SURFACES.filter((provider) => {
207
+ const rule = `${provider}.*`;
208
+ if (denied(rule)) return false;
209
+
210
+ const hasRow = runtime.config.connections.some(
211
+ (connection) => connection.provider === provider,
212
+ );
213
+ const granted = runtime.config.policy.allow.some(
214
+ (entry) => entry.capability === '*' || entry.capability === rule,
215
+ );
216
+ return !hasRow || !granted;
217
+ });
202
218
 
203
- if (!hasSetupRow || !grantsSetup) {
219
+ if (missing.length > 0) {
204
220
  warnings.push({
205
- kind: 'no_setup_connection',
221
+ kind: 'no_owner_layer',
206
222
  message:
207
- `this profile ${hasSetupRow ? 'does not grant "setup.*"' : 'has no "setup" connection'}` +
208
- ', so an agent cannot see what is configured run: lanes link connect setup',
209
- fix: forSelection('lanes link connect setup'),
223
+ `this profile cannot reach its own ${missing.join(', ')} ` +
224
+ 'the connection row or the allow rule is missing, and either alone serves nothing. ' +
225
+ 'Any of start, connect or deploy repairs it',
226
+ fix: forSelection('lanes link start'),
210
227
  });
211
228
  }
212
229
 
@@ -1,5 +1,6 @@
1
1
  import { startEndpoint } from '#server/endpoint.ts';
2
2
  import { streamLogger } from '#server/logging.ts';
3
+ import { repairOwnerLayer } from '../../config-repair.ts';
3
4
  import { announce, ok, print, style, warn } from '../../output.ts';
4
5
  import { staleNudge } from '../../release.ts';
5
6
  import { resolveProfile, type GlobalFlags } from '../../runtime.ts';
@@ -16,6 +17,26 @@ export async function start(
16
17
  const { resolution } = await resolveProfile(flags);
17
18
  announce(resolution);
18
19
 
20
+ // Before the bootstrap, and only here.
21
+ //
22
+ // This is the one command an existing install runs without being told to, so
23
+ // it is how a profile written before ADR-050 comes to have memory, tasks,
24
+ // assets, skills and the vault at all — `connect` and `deploy` repair too, but
25
+ // someone who is already set up may not run either for months. What it writes
26
+ // is the rows and rules a fresh profile is created with; a `deny` covering a
27
+ // surface is left alone, which is how one stays off.
28
+ //
29
+ // Not inside `startEndpoint`, which the container entrypoint also calls: a
30
+ // deployed revision holds `objectViewer` on `profiles/` (ADR-023) and must not
31
+ // be the thing that edits config. The repair belongs to the control plane
32
+ // (ADR-007), and this is the control plane.
33
+ //
34
+ // Scoped as the serving is: `--only` serves one profile, so it repairs one.
35
+ await repairOwnerLayer(
36
+ resolution.workspaceRoot,
37
+ flags.only ? [resolution.profile] : undefined,
38
+ );
39
+
19
40
  // The bootstrap itself lives in `endpoint.ts`, shared with the container
20
41
  // entrypoint. What stays here is what a terminal wants: the plan, printed as
21
42
  // it is applied, and the endpoint at the end.
@@ -0,0 +1,132 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { ConfigError } from '#profile';
4
+ import { scopeNamespace } from '#dispatch';
5
+ import { scopeBlobStore, type BlobStore } from '#stores/blobs';
6
+ import { assetStorage } from '#providers/owner.ts';
7
+ import { heading, ok, print, style, table } from '../../output.ts';
8
+ import type { Runtime } from '../../runtime.ts';
9
+ import {
10
+ agreed,
11
+ ownerConnection,
12
+ required,
13
+ withRuntime,
14
+ type OwnerFlags,
15
+ } from './shared.ts';
16
+
17
+ /** `lanes link assets` — files the owner wants kept. */
18
+
19
+ export async function assetsList(flags: OwnerFlags): Promise<void> {
20
+ await withRuntime(flags, async (runtime) => {
21
+ const assets = await assetStorage.all(assetsStore(runtime, flags));
22
+
23
+ heading(`Assets (${assets.length})`);
24
+ if (assets.length === 0) {
25
+ print(style.dim(' none — keep one with: lanes link assets add <file>'));
26
+ return;
27
+ }
28
+
29
+ table(
30
+ assets.map((asset) => [
31
+ ` ${asset.name}`,
32
+ style.dim(asset.contentType),
33
+ style.dim(assetStorage.humanBytes(asset.bytes)),
34
+ style.dim(asset.modifiedAt.slice(0, 10)),
35
+ ]),
36
+ );
37
+ });
38
+ }
39
+
40
+ /**
41
+ * `lanes link assets add <file>` — a path on this machine.
42
+ *
43
+ * Only a path, where the capability takes five sources. The other four exist
44
+ * because the endpoint may not be on the caller's machine; a CLI is, by
45
+ * definition, already there, so a URL or a staged handle here would be
46
+ * ceremony over `curl -O`.
47
+ */
48
+ export async function assetsAdd(path: string | undefined, flags: OwnerFlags): Promise<void> {
49
+ const from = required(path, 'lanes link assets add <file>');
50
+
51
+ let bytes: Uint8Array;
52
+ try {
53
+ bytes = new Uint8Array(await readFile(from));
54
+ } catch (failure) {
55
+ const code = (failure as { code?: string }).code;
56
+ if (code === 'ENOENT') throw new ConfigError(`No file at ${from}.`);
57
+ if (code === 'EISDIR') throw new ConfigError(`${from} is a directory, not a file.`);
58
+ throw new ConfigError(`Could not read ${from} — ${(failure as Error).message}`);
59
+ }
60
+
61
+ const name = flags.name ?? basename(from);
62
+ assetStorage.assertName(name);
63
+
64
+ await withRuntime(flags, async (runtime) => {
65
+ const store = assetsStore(runtime, flags);
66
+ const replaced = await store.has(name);
67
+
68
+ // Only when told. Left off, the store infers from the extension and writes no
69
+ // sidecar; `--content-type` is for the file whose name does not say what it
70
+ // is, which is the only case worth a `<name>.meta` beside it.
71
+ await store.put(name, bytes, flags.contentType ? { contentType: flags.contentType } : {});
72
+
73
+ print(
74
+ ok(
75
+ `${replaced ? 'replaced' : 'kept'} ${style.bold(name)} — ` +
76
+ `${assetStorage.humanBytes(bytes.byteLength)}, sha256 ${assetStorage.digest(bytes).slice(0, 12)}…`,
77
+ ),
78
+ );
79
+ });
80
+ }
81
+
82
+ /**
83
+ * `lanes link assets get <name>` — write the bytes to stdout.
84
+ *
85
+ * Bytes rather than a description, unlike the capability: this end of the pipe is
86
+ * a shell, so `lanes link assets get invoice.pdf > invoice.pdf` is the useful
87
+ * thing and there is no context window to protect. Refuses a terminal for the
88
+ * same reason `curl` warns about it — binary into a tty is a mess nobody wanted.
89
+ */
90
+ export async function assetsGet(name: string | undefined, flags: OwnerFlags): Promise<void> {
91
+ const wanted = required(name, 'lanes link assets get <name> > <file>');
92
+
93
+ await withRuntime({ ...flags, raw: true }, async (runtime) => {
94
+ const store = assetsStore(runtime, flags);
95
+ const bytes = await store.get(wanted);
96
+ if (bytes === null) throw new ConfigError(`No asset "${wanted}" in this profile.`);
97
+
98
+ if (process.stdout.isTTY) {
99
+ throw new ConfigError(
100
+ `"${wanted}" would be written to your terminal. Redirect it:\n` +
101
+ ` lanes link assets get ${wanted} > ${wanted}`,
102
+ );
103
+ }
104
+
105
+ await Bun.write(Bun.stdout, bytes);
106
+ });
107
+ }
108
+
109
+ export async function assetsRemove(name: string | undefined, flags: OwnerFlags): Promise<void> {
110
+ const wanted = required(name, 'lanes link assets remove <name>');
111
+
112
+ await withRuntime(flags, async (runtime) => {
113
+ const store = assetsStore(runtime, flags);
114
+ const asset = await assetStorage.find(store, wanted);
115
+ if (!asset) throw new ConfigError(`No asset "${wanted}" in this profile.`);
116
+
117
+ print(` ${style.bold(asset.name)} ${assetStorage.describe(asset)}`);
118
+ if (!(await agreed(flags, 'Delete this file?'))) return;
119
+
120
+ await store.delete(wanted);
121
+ print(ok(`deleted ${style.bold(wanted)}`));
122
+ });
123
+ }
124
+
125
+ /**
126
+ * The blob namespace core would scope this provider to — see `tasks.ts` for why
127
+ * it is built from the same two functions rather than spelled as a path.
128
+ */
129
+ export function assetsStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
130
+ const connection = ownerConnection(runtime.config, 'assets', flags);
131
+ return scopeBlobStore(runtime.storage, scopeNamespace('assets', connection));
132
+ }
@@ -4,11 +4,11 @@ import { confirm } from '../../prompt.ts';
4
4
  import { openRuntime, type GlobalFlags, type Runtime } from '../../runtime.ts';
5
5
 
6
6
  /**
7
- * What `lanes link memory`, `lanes link skills` and `lanes link vault` all need: the flag shape, the
8
- * runtime wrapper, connection resolution, and the two prompts.
7
+ * What every `lanes link` command over the owner's own data needs: the flag
8
+ * shape, the runtime wrapper, connection resolution, and the two prompts.
9
9
  *
10
- * All four commands are the same shape — open a runtime, announce, act, close —
11
- * so the wrapper lives here rather than three times over.
10
+ * All of them are the same shape — open a runtime, announce, act, close — so the
11
+ * wrapper lives here rather than once per noun.
12
12
  */
13
13
 
14
14
  export interface OwnerFlags extends GlobalFlags {
@@ -18,6 +18,14 @@ export interface OwnerFlags extends GlobalFlags {
18
18
  readonly tag?: string | undefined;
19
19
  readonly description?: string | undefined;
20
20
  readonly file?: string | undefined;
21
+ /** `tasks`: which status to set, or to filter a listing by. */
22
+ readonly status?: string | undefined;
23
+ /** `tasks`: when it is due, as the owner would write it. Empty clears it. */
24
+ readonly due?: string | undefined;
25
+ /** `assets`: what to call the stored file, where the path's basename is wrong. */
26
+ readonly name?: string | undefined;
27
+ /** `assets`: for the file whose extension does not say what it is. */
28
+ readonly contentType?: string | undefined;
21
29
  /** Reveal a vault value on a terminal. */
22
30
  readonly show?: boolean | undefined;
23
31
  /** Print only the value, for `$(…)`. */
@@ -102,6 +110,22 @@ export async function readStdin(usage: string, what: string): Promise<string> {
102
110
  return text;
103
111
  }
104
112
 
113
+ /**
114
+ * Read stdin where there may legitimately be nothing on it.
115
+ *
116
+ * `readStdin` refuses both a terminal and an empty pipe, which is right for
117
+ * `memory write` and `vault set` — a command whose whole subject arrived empty
118
+ * has been mis-invoked. It is wrong for `tasks add`, whose subject is the title
119
+ * on argv and whose notes are optional: refusing there made
120
+ * `lanes link tasks add "x"` fail in every non-interactive context — a script, a
121
+ * cron, a `< /dev/null` — while working by hand, which is the worst shape for a
122
+ * bug to have.
123
+ */
124
+ export async function optionalStdin(): Promise<string> {
125
+ if (process.stdin.isTTY) return '';
126
+ return (await Bun.stdin.text()).replace(/\n$/, '');
127
+ }
128
+
105
129
  /** Confirm a destructive action, unless `--yes` already answered. */
106
130
  export async function agreed(flags: OwnerFlags, question: string): Promise<boolean> {
107
131
  if (flags.yes) return true;