@lanes-sh/link 0.4.1 → 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.
- package/README.md +20 -9
- package/instructions/agents/lanes-link-scout.md +14 -3
- package/instructions/skills/lanes-link/SKILL.md +80 -3
- package/package.json +2 -2
- package/src/cli/argv.ts +7 -0
- package/src/cli/commands/connect/index.ts +9 -6
- package/src/cli/commands/connection.ts +298 -0
- package/src/cli/commands/operate/inspect.ts +37 -20
- package/src/cli/commands/operate/serve.ts +21 -0
- package/src/cli/commands/owner/assets.ts +132 -0
- package/src/cli/commands/owner/shared.ts +28 -4
- package/src/cli/commands/owner/tasks.ts +194 -0
- package/src/cli/commands/owner.ts +9 -4
- package/src/cli/config-edit.ts +33 -7
- package/src/cli/config-repair.ts +115 -11
- package/src/cli/dispatch-owner.ts +49 -8
- package/src/cli/lanes.ts +1 -1
- package/src/cli/main.ts +21 -2
- package/src/cli/provider-marks.ts +1 -1
- package/src/cli/runtime/registry.ts +10 -2
- package/src/cli/selection.ts +14 -0
- package/src/cli/usage.ts +18 -2
- package/src/connectivity/mail/attachments.ts +5 -1
- package/src/connectivity/mail/index.ts +6 -1
- package/src/connectivity/manifest/provider.ts +15 -2
- package/src/deployments/deploy.ts +3 -2
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/servable.ts +1 -1
- package/src/deployments/upload.ts +0 -53
- package/src/profile/load.ts +46 -0
- package/src/providers/assets/provider.ts +337 -0
- package/src/providers/assets/store.ts +167 -0
- package/src/providers/google/index.ts +1 -1
- package/src/providers/google/tasks/index.ts +3 -3
- package/src/providers/google/tasks/redact.ts +21 -11
- package/src/providers/index.ts +3 -3
- package/src/providers/owner.ts +39 -19
- package/src/providers/setup/plan.ts +17 -1
- package/src/providers/tasks/provider.ts +370 -0
- package/src/providers/tasks/store.ts +248 -0
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/instructions.ts +67 -8
|
@@ -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
|
|
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
|
|
11
|
-
*
|
|
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;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ConfigError } from '#profile';
|
|
2
|
+
import { scopeNamespace } from '#dispatch';
|
|
3
|
+
import { scopeBlobStore, type BlobStore } from '#stores/blobs';
|
|
4
|
+
import { ACTIVE_STATUSES, TASK_STATUSES, taskStorage, type TaskStatus } from '#providers/owner.ts';
|
|
5
|
+
import { heading, ok, print, style, table } from '../../output.ts';
|
|
6
|
+
import type { Runtime } from '../../runtime.ts';
|
|
7
|
+
import {
|
|
8
|
+
agreed,
|
|
9
|
+
optionalStdin,
|
|
10
|
+
ownerConnection,
|
|
11
|
+
required,
|
|
12
|
+
withRuntime,
|
|
13
|
+
type OwnerFlags,
|
|
14
|
+
} from './shared.ts';
|
|
15
|
+
|
|
16
|
+
/** `lanes link tasks` — what the owner has to do. */
|
|
17
|
+
|
|
18
|
+
export async function tasksList(flags: OwnerFlags): Promise<void> {
|
|
19
|
+
await withRuntime(flags, async (runtime) => {
|
|
20
|
+
const store = tasksStore(runtime, flags);
|
|
21
|
+
|
|
22
|
+
// The same default the `tasks.list` capability applies, and for the same
|
|
23
|
+
// reason: the question is what is outstanding, and a list that grows forever
|
|
24
|
+
// is one nobody reads. `--status all` is the escape hatch.
|
|
25
|
+
const wanted = statusFilter(flags.status);
|
|
26
|
+
const tasks = (await taskStorage.all(store)).filter(
|
|
27
|
+
(task) =>
|
|
28
|
+
(wanted === null || wanted.has(task.status)) && (!flags.tag || task.tags.includes(flags.tag)),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
heading(`Tasks (${tasks.length}${wanted === null ? '' : ' outstanding'})`);
|
|
32
|
+
if (tasks.length === 0) {
|
|
33
|
+
print(style.dim(' none — add one with: lanes link tasks add <title>'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
table(
|
|
38
|
+
tasks.map((task) => [
|
|
39
|
+
` ${task.id}`,
|
|
40
|
+
task.status,
|
|
41
|
+
task.title,
|
|
42
|
+
task.due ? style.dim(`due ${task.due}`) : '',
|
|
43
|
+
task.tags.length > 0 ? style.dim(task.tags.join(', ')) : '',
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function tasksGet(id: string | undefined, flags: OwnerFlags): Promise<void> {
|
|
50
|
+
const taskId = required(id, 'lanes link tasks get <id>');
|
|
51
|
+
|
|
52
|
+
await withRuntime(flags, async (runtime) => {
|
|
53
|
+
const task = await taskStorage.read(tasksStore(runtime, flags), taskId);
|
|
54
|
+
if (!task) throw new ConfigError(`No task "${taskId}" in this profile.`);
|
|
55
|
+
|
|
56
|
+
print('');
|
|
57
|
+
print(` ${style.bold(task.title)}`);
|
|
58
|
+
print(style.dim(` ${task.status}${task.due ? ` due ${task.due}` : ''}`));
|
|
59
|
+
if (task.tags.length > 0) print(style.dim(` ${task.tags.join(', ')}`));
|
|
60
|
+
if (task.body.length > 0) {
|
|
61
|
+
print('');
|
|
62
|
+
print(task.body);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `lanes link tasks add <title>` — the title on argv, notes optional on stdin.
|
|
69
|
+
*
|
|
70
|
+
* Unlike `memory write`, the title is the argument and the body is optional: a
|
|
71
|
+
* task is usually one line, and demanding a heredoc to write "chase the invoice"
|
|
72
|
+
* would make the common case the awkward one. So `optionalStdin` rather than
|
|
73
|
+
* `readStdin` — see its docstring for what refusing an empty pipe here broke.
|
|
74
|
+
*/
|
|
75
|
+
export async function tasksAdd(title: string | undefined, flags: OwnerFlags): Promise<void> {
|
|
76
|
+
const given = required(title, 'lanes link tasks add <title> (notes on stdin, optional)');
|
|
77
|
+
const notes = await optionalStdin();
|
|
78
|
+
|
|
79
|
+
await withRuntime(flags, async (runtime) => {
|
|
80
|
+
const store = tasksStore(runtime, flags);
|
|
81
|
+
const id = taskStorage.slugify(given);
|
|
82
|
+
const existing = await taskStorage.read(store, id);
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
|
|
85
|
+
await taskStorage.write(store, {
|
|
86
|
+
id,
|
|
87
|
+
title: given,
|
|
88
|
+
status: assertStatus(flags.status) ?? 'open',
|
|
89
|
+
tags: flags.tag ? [flags.tag] : (existing?.tags ?? []),
|
|
90
|
+
...(flags.due ? { due: flags.due } : {}),
|
|
91
|
+
createdAt: existing?.createdAt ?? now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
body: notes,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
print(ok(`${existing ? 'replaced' : 'added'} task ${style.bold(id)}`));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `lanes link tasks update <id> --status done`.
|
|
102
|
+
*
|
|
103
|
+
* Omitted flags leave their fields alone, which is what makes this the way to
|
|
104
|
+
* close a task rather than delete it: the record of having done it is the useful
|
|
105
|
+
* part, and `--status done` keeps everything else.
|
|
106
|
+
*/
|
|
107
|
+
export async function tasksUpdate(id: string | undefined, flags: OwnerFlags): Promise<void> {
|
|
108
|
+
const taskId = required(id, 'lanes link tasks update <id> --status <status>');
|
|
109
|
+
|
|
110
|
+
await withRuntime(flags, async (runtime) => {
|
|
111
|
+
const store = tasksStore(runtime, flags);
|
|
112
|
+
const existing = await taskStorage.read(store, taskId);
|
|
113
|
+
if (!existing) throw new ConfigError(`No task "${taskId}" in this profile.`);
|
|
114
|
+
|
|
115
|
+
const status = assertStatus(flags.status);
|
|
116
|
+
if (!status && !flags.title && !flags.due && !flags.tag) {
|
|
117
|
+
throw new ConfigError(
|
|
118
|
+
`Nothing to change. Pass --status, --title, --due or --tag.\n` +
|
|
119
|
+
` statuses: ${TASK_STATUSES.join(', ')}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// `due` is taken off the existing record rather than spread and overwritten,
|
|
124
|
+
// because spreading cannot remove a key — the same trap the provider's
|
|
125
|
+
// `update` documents.
|
|
126
|
+
const { due: previous, ...rest } = existing;
|
|
127
|
+
const due = flags.due === '' ? undefined : (flags.due ?? previous);
|
|
128
|
+
|
|
129
|
+
await taskStorage.write(store, {
|
|
130
|
+
...rest,
|
|
131
|
+
title: flags.title ?? existing.title,
|
|
132
|
+
status: status ?? existing.status,
|
|
133
|
+
tags: flags.tag ? [flags.tag] : existing.tags,
|
|
134
|
+
...(due ? { due } : {}),
|
|
135
|
+
updatedAt: new Date().toISOString(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
print(ok(`updated task ${style.bold(taskId)} — now ${status ?? existing.status}`));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function tasksRemove(id: string | undefined, flags: OwnerFlags): Promise<void> {
|
|
143
|
+
const taskId = required(id, 'lanes link tasks remove <id>');
|
|
144
|
+
|
|
145
|
+
await withRuntime(flags, async (runtime) => {
|
|
146
|
+
const store = tasksStore(runtime, flags);
|
|
147
|
+
const task = await taskStorage.read(store, taskId);
|
|
148
|
+
if (!task) throw new ConfigError(`No task "${taskId}" in this profile.`);
|
|
149
|
+
|
|
150
|
+
print(` ${style.bold(task.id)} ${task.status} ${task.title}`);
|
|
151
|
+
print(
|
|
152
|
+
style.dim(' deleting loses the record that it happened — "update --status done" keeps it'),
|
|
153
|
+
);
|
|
154
|
+
if (!(await agreed(flags, 'Delete this task?'))) return;
|
|
155
|
+
|
|
156
|
+
await store.delete(taskStorage.key(taskId));
|
|
157
|
+
print(ok(`deleted task ${style.bold(taskId)}`));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `--status` as a filter: a named one, `all`, or the outstanding set by default.
|
|
163
|
+
*
|
|
164
|
+
* `null` means every status. Returning a set rather than a predicate so the
|
|
165
|
+
* heading can say whether it narrowed.
|
|
166
|
+
*/
|
|
167
|
+
function statusFilter(raw: string | undefined): Set<TaskStatus> | null {
|
|
168
|
+
if (raw === 'all') return null;
|
|
169
|
+
if (raw === undefined) return new Set(ACTIVE_STATUSES);
|
|
170
|
+
return new Set([assertStatus(raw)!]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function assertStatus(raw: string | undefined): TaskStatus | undefined {
|
|
174
|
+
if (raw === undefined) return undefined;
|
|
175
|
+
if (!(TASK_STATUSES as readonly string[]).includes(raw)) {
|
|
176
|
+
throw new ConfigError(
|
|
177
|
+
`Unknown status "${raw}". One of: ${TASK_STATUSES.join(', ')}` +
|
|
178
|
+
'\n (or "all", when filtering a listing)',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return raw as TaskStatus;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The blob namespace core would scope this provider to.
|
|
186
|
+
*
|
|
187
|
+
* Built from `scopeNamespace` and `scopeBlobStore` — the same two functions
|
|
188
|
+
* `buildProviderContext` uses — rather than from a path spelled out again, so the
|
|
189
|
+
* CLI cannot address a different directory from the provider.
|
|
190
|
+
*/
|
|
191
|
+
export function tasksStore(runtime: Runtime, flags: OwnerFlags): BlobStore {
|
|
192
|
+
const connection = ownerConnection(runtime.config, 'tasks', flags);
|
|
193
|
+
return scopeBlobStore(runtime.storage, scopeNamespace('tasks', connection));
|
|
194
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `lanes link memory`, `
|
|
2
|
+
* `lanes link memory`, `tasks`, `assets`, `skills`, `vault` — the owner layer's
|
|
3
|
+
* control plane.
|
|
3
4
|
*
|
|
4
5
|
* The layer shipped in M4 with no CLI at all, so the two stores holding the
|
|
5
6
|
* owner's *own* data were reachable only by an agent, and the one thing that
|
|
@@ -20,9 +21,9 @@
|
|
|
20
21
|
* both sides now: from here because this is the owner's control plane, and over
|
|
21
22
|
* MCP because ADR-014 §1 decided a policy-gated grant beats a missing path.
|
|
22
23
|
*
|
|
23
|
-
* One noun per file — `memory.ts`, `
|
|
24
|
-
*
|
|
25
|
-
* wrapper, connection resolution, and the two prompts.
|
|
24
|
+
* One noun per file — `memory.ts`, `tasks.ts`, `assets.ts`, `skills.ts`,
|
|
25
|
+
* `vault.ts` — over the shape they all share in `shared.ts`: the flag type, the
|
|
26
|
+
* open-announce-act-close wrapper, connection resolution, and the two prompts.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
export {
|
|
@@ -33,6 +34,10 @@ export {
|
|
|
33
34
|
memoryWrite,
|
|
34
35
|
} from './owner/memory.ts';
|
|
35
36
|
|
|
37
|
+
export { tasksAdd, tasksGet, tasksList, tasksRemove, tasksUpdate } from './owner/tasks.ts';
|
|
38
|
+
|
|
39
|
+
export { assetsAdd, assetsGet, assetsList, assetsRemove } from './owner/assets.ts';
|
|
40
|
+
|
|
36
41
|
export { skillsAdd, skillsList, skillsRemove, skillsShow } from './owner/skills.ts';
|
|
37
42
|
|
|
38
43
|
export {
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -264,13 +264,27 @@ oauth_apps: {}
|
|
|
264
264
|
# reports — an address, a workspace — so this list says whose data is reachable
|
|
265
265
|
# without having to look anything up.
|
|
266
266
|
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
267
|
+
# The six below hold no account, and that is why they are here already: they
|
|
268
|
+
# reach your own material rather than anybody's API, so there was never anything
|
|
269
|
+
# for a connect step to authorise (ADR-050). What each one is:
|
|
270
|
+
#
|
|
271
|
+
# memory what you want remembered between sessions
|
|
272
|
+
# tasks what you have to do, each with a status
|
|
273
|
+
# assets files you want kept, by name
|
|
274
|
+
# skills procedures you have written, handed to an agent as instructions
|
|
275
|
+
# vault passwords and API keys, released one at a time
|
|
276
|
+
# setup what is connected here, and what connecting more would take
|
|
277
|
+
#
|
|
278
|
+
# Nothing is stored in any of them until you or an agent puts something there,
|
|
279
|
+
# and none of them can read an account. To switch one off, deny it below —
|
|
280
|
+
# deleting the entry no longer works, because the next connect or deploy puts it
|
|
281
|
+
# back.
|
|
273
282
|
connections:
|
|
283
|
+
- { id: main, provider: memory, account: Memory }
|
|
284
|
+
- { id: main, provider: tasks, account: Tasks }
|
|
285
|
+
- { id: main, provider: assets, account: Assets }
|
|
286
|
+
- { id: main, provider: skills, account: Skills }
|
|
287
|
+
- { id: main, provider: vault, account: Vault }
|
|
274
288
|
- { id: main, provider: setup, account: Setup }
|
|
275
289
|
|
|
276
290
|
# Only what is listed here is reachable, and an empty policy grants nothing.
|
|
@@ -284,8 +298,20 @@ connections:
|
|
|
284
298
|
# allow: ['*'] everything, which is what connect writes
|
|
285
299
|
# allow: [notion.*, gmail.*] two providers
|
|
286
300
|
# deny: [gmail.send_message] a deny always beats an allow
|
|
301
|
+
#
|
|
302
|
+
# The rules below grant each of the six its whole namespace, writes included —
|
|
303
|
+
# the same thing "connect memory" wrote when it was a command you had to run.
|
|
304
|
+
# Narrowing is one line, and these are the three worth knowing:
|
|
305
|
+
#
|
|
306
|
+
# deny: [memory.write] memory becomes read-only
|
|
307
|
+
# deny: [skills.manage.*] skills can be invoked but not authored
|
|
308
|
+
# deny: [vault.put, vault.remove] nothing new can be stored
|
|
309
|
+
#
|
|
310
|
+
# A vault read is not granted by "vault.*" alone: each stored item is its own
|
|
311
|
+
# "vault.get.<id>" capability and only appears after a restart, so a write can
|
|
312
|
+
# never hand itself a read (ADR-012).
|
|
287
313
|
policy:
|
|
288
|
-
allow: [setup.*]
|
|
314
|
+
allow: [memory.*, tasks.*, assets.*, skills.*, vault.*, setup.*]
|
|
289
315
|
deny: []
|
|
290
316
|
`;
|
|
291
317
|
}
|
package/src/cli/config-repair.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { listProfiles } from '#profile';
|
|
2
|
+
import { ConfigDocument } from './config-edit.ts';
|
|
3
|
+
import { ok, print, style, warn } from './output.ts';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Giving a profile a reserved provider it is missing, without undoing a choice.
|
|
@@ -9,22 +11,49 @@ import type { ConfigDocument } from './config-edit.ts';
|
|
|
9
11
|
* was already there: that file knows how to *edit* YAML safely, and this one
|
|
10
12
|
* knows what a reserved provider needs to be reachable at all.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
* connection row nobody would think to write
|
|
14
|
-
* connected, and `identity`, which says who the owner is. Both are repaired the
|
|
14
|
+
* The owner layer holds no account and is therefore invisible without a
|
|
15
|
+
* connection row nobody would think to write. Every one of them is repaired the
|
|
15
16
|
* same way and the rules below are subtle enough that a second copy would drift
|
|
16
17
|
* — which is the whole reason this is one function taking a provider id rather
|
|
17
|
-
* than
|
|
18
|
+
* than several that look alike.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
/** The reserved provider ids that hold no account, and the label each row carries. */
|
|
21
22
|
const RESERVED_SURFACES = {
|
|
23
|
+
memory: 'Memory',
|
|
24
|
+
tasks: 'Tasks',
|
|
25
|
+
assets: 'Assets',
|
|
26
|
+
skills: 'Skills',
|
|
27
|
+
vault: 'Vault',
|
|
22
28
|
setup: 'Setup',
|
|
23
29
|
identity: 'Identity',
|
|
24
30
|
} as const;
|
|
25
31
|
|
|
26
32
|
type ReservedSurface = keyof typeof RESERVED_SURFACES;
|
|
27
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The ones a profile gets whether it asked or not.
|
|
36
|
+
*
|
|
37
|
+
* `identity` is the exception and stays off this list, for the reason
|
|
38
|
+
* `ensureIdentityConnection` gives: a profile with no identity block has nothing
|
|
39
|
+
* for the surface to report, so registering a tool that answers "nothing
|
|
40
|
+
* declared" would spend instructions budget to say so. Everything else here
|
|
41
|
+
* reaches the owner's own material and is empty until they put something in it,
|
|
42
|
+
* which is ADR-050's whole argument — so a profile written before those existed
|
|
43
|
+
* gets them on the next command rather than needing five of its own.
|
|
44
|
+
*
|
|
45
|
+
* Ordered as `RESERVED_PROVIDER_IDS` is, so a repair reports in the order the
|
|
46
|
+
* template writes and a diff between the two reads as a diff.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_SURFACES: readonly ReservedSurface[] = [
|
|
49
|
+
'memory',
|
|
50
|
+
'tasks',
|
|
51
|
+
'assets',
|
|
52
|
+
'skills',
|
|
53
|
+
'vault',
|
|
54
|
+
'setup',
|
|
55
|
+
];
|
|
56
|
+
|
|
28
57
|
/**
|
|
29
58
|
* What a repair did, split by what a caller does with each half.
|
|
30
59
|
*
|
|
@@ -162,14 +191,28 @@ function patternsIn(rules: unknown, now = Date.now()): string[] {
|
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
/**
|
|
165
|
-
* The
|
|
194
|
+
* The owner layer, which every profile is expected to have.
|
|
195
|
+
*
|
|
196
|
+
* Was `ensureSetupConnection`, and grew rather than gained siblings: the callers
|
|
197
|
+
* are the same three, and what changed is how many surfaces "a profile should be
|
|
198
|
+
* able to reach its own material" covers. Repairs are accumulated so a caller
|
|
199
|
+
* reports one list — five separate calls would print five near-identical blocks
|
|
200
|
+
* on the one upgrade where any of them fire.
|
|
166
201
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* each of them says less than the name did.
|
|
202
|
+
* Each surface is still decided independently, so a profile that denied exactly
|
|
203
|
+
* one of them keeps that decision while the rest are repaired.
|
|
170
204
|
*/
|
|
171
|
-
export function
|
|
172
|
-
|
|
205
|
+
export function ensureOwnerLayer(document: ConfigDocument): SurfaceRepair {
|
|
206
|
+
const changes: string[] = [];
|
|
207
|
+
const granted: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (const provider of DEFAULT_SURFACES) {
|
|
210
|
+
const repair = ensureReservedConnection(document, provider);
|
|
211
|
+
changes.push(...repair.changes);
|
|
212
|
+
granted.push(...repair.granted);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { changes, granted };
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
/**
|
|
@@ -184,3 +227,64 @@ export function ensureSetupConnection(document: ConfigDocument): SurfaceRepair {
|
|
|
184
227
|
export function ensureIdentityConnection(document: ConfigDocument): SurfaceRepair {
|
|
185
228
|
return ensureReservedConnection(document, 'identity');
|
|
186
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply that repair across a workspace, saving and reporting what changed.
|
|
233
|
+
*
|
|
234
|
+
* Here rather than in `#deployments`, where it was, because `start` needs it as
|
|
235
|
+
* much as `deploy` does — more, in fact: `start` is the one command an existing
|
|
236
|
+
* install runs without being asked to, so it is the path by which a profile
|
|
237
|
+
* written before ADR-050 gets the layer at all. Two copies of a function that
|
|
238
|
+
* widens a policy is not a thing to have.
|
|
239
|
+
*
|
|
240
|
+
* **The caller scopes it**, and for `deploy` that is exactly the set being
|
|
241
|
+
* uploaded: a profile it sends is a profile the endpoint will serve, so
|
|
242
|
+
* repairing a narrower set would leave a served profile without the surfaces.
|
|
243
|
+
* Note what a `--profile` flag does not mean — it is the flag alone, so a
|
|
244
|
+
* profile resolved from the environment leaves it undefined and that reads as
|
|
245
|
+
* the whole workspace.
|
|
246
|
+
*
|
|
247
|
+
* *Which files are profiles* comes from `listProfiles`, never from an allowlist
|
|
248
|
+
* of what is safe to copy: that would happily hand over a committed
|
|
249
|
+
* `personal.example.yaml` or a nested `profiles/archive/old.yaml`, and this
|
|
250
|
+
* opens and validates what it is given — which once turned a template into a
|
|
251
|
+
* `ConfigError` aborting a deploy after provisioning had made cloud resources.
|
|
252
|
+
*
|
|
253
|
+
* A profile that cannot be read is warned about rather than fatal: the repair is
|
|
254
|
+
* a courtesy on the way past, and the caller's real work should still happen.
|
|
255
|
+
* Not silent, though — nothing else widens a policy without being asked.
|
|
256
|
+
*
|
|
257
|
+
* CLI-side by construction, like everything else in this file: a deployed
|
|
258
|
+
* revision holds `objectViewer` on `profiles/` (ADR-023) and could not write
|
|
259
|
+
* this even if the code let it.
|
|
260
|
+
*/
|
|
261
|
+
export async function repairOwnerLayer(
|
|
262
|
+
workspaceRoot: string,
|
|
263
|
+
profiles: readonly string[] | undefined,
|
|
264
|
+
): Promise<void> {
|
|
265
|
+
const wanted = profiles === undefined ? undefined : new Set(profiles);
|
|
266
|
+
|
|
267
|
+
for (const name of await listProfiles(workspaceRoot)) {
|
|
268
|
+
if (wanted !== undefined && !wanted.has(name)) continue;
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const document = await ConfigDocument.open(workspaceRoot, name);
|
|
272
|
+
const repair = ensureOwnerLayer(document);
|
|
273
|
+
if (!repaired(repair)) continue;
|
|
274
|
+
|
|
275
|
+
await document.save();
|
|
276
|
+
|
|
277
|
+
print(ok(`gave ${style.bold(name)} its own owner layer`));
|
|
278
|
+
for (const change of repairLines(repair)) print(` ${style.dim(change)}`);
|
|
279
|
+
print(
|
|
280
|
+
` ${style.dim('memory, tasks, assets, skills, vault and setup — your own material, no account behind any of them')}`,
|
|
281
|
+
);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
print(
|
|
284
|
+
warn(
|
|
285
|
+
`could not give ${name} its owner layer: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`,
|
|
286
|
+
),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|