@lorekit/cli 1.25.0 → 1.26.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 +45 -2
- package/bin/lorekit.mjs +67 -5
- package/package.json +1 -1
- package/src/core/lessons.mjs +22 -12
- package/src/deeplink-pure.mjs +194 -0
- package/src/doctor.mjs +50 -16
- package/src/hook.mjs +7 -1
- package/src/link.mjs +139 -0
- package/src/list.mjs +13 -0
- package/src/mcp-server.mjs +49 -6
- package/src/origin.mjs +186 -0
- package/src/search.mjs +13 -0
- package/src/show.mjs +9 -0
- package/src/store/format.mjs +7 -0
- package/src/store/local.mjs +15 -1
- package/src/store/remote.mjs +11 -1
- package/src/telemetry.mjs +4 -4
- package/src/tree.mjs +13 -0
- package/src/write.mjs +39 -0
package/src/link.mjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// `lorekit link` (alias `url`) — print a shareable dashboard deep-link URL to
|
|
2
|
+
// stdout for the current directory's context, a scope, or a specific lesson.
|
|
3
|
+
//
|
|
4
|
+
// Read-only and network-free: it derives scopes from git and builds a URL — it
|
|
5
|
+
// never talks to a store. The URL alone is written to stdout (pipeable, e.g.
|
|
6
|
+
// `lorekit link | pbcopy`); any advisory note goes to stderr, and only when
|
|
7
|
+
// stderr is a TTY, so a pipe stays clean. Human-facing, so the bin wraps it in
|
|
8
|
+
// `traceCommand`.
|
|
9
|
+
//
|
|
10
|
+
// lorekit link → /lore filtered to the cwd's most-specific scope
|
|
11
|
+
// lorekit link <scope> → /lore?scope="<scope>"
|
|
12
|
+
// lorekit link <scope> <key> → a link that opens that lesson's detail sheet
|
|
13
|
+
// lorekit link <scope::key> → same, via the copy-paste shorthand
|
|
14
|
+
//
|
|
15
|
+
// Every param is JSON-encoded per the `useUrlState` contract (see
|
|
16
|
+
// `deeplink-pure.mjs`) — a raw `?scope=global` would silently mean "all scopes".
|
|
17
|
+
import process from 'node:process';
|
|
18
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
19
|
+
import { deriveScope } from './scope.mjs';
|
|
20
|
+
import { scopeIssue } from './lessons-view.mjs';
|
|
21
|
+
import {
|
|
22
|
+
resolveAppBase,
|
|
23
|
+
buildLoreUrl,
|
|
24
|
+
mostSpecificScope,
|
|
25
|
+
parseOwnerArg,
|
|
26
|
+
parseViewArg,
|
|
27
|
+
parseRangeArg,
|
|
28
|
+
resolveScopeArg,
|
|
29
|
+
surfaceFor,
|
|
30
|
+
} from './deeplink-pure.mjs';
|
|
31
|
+
import { log, err } from './util.mjs';
|
|
32
|
+
|
|
33
|
+
export async function link(args) {
|
|
34
|
+
const root = resolveProjectRoot(args.dir);
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
const scopeInfo = deriveScope(root);
|
|
37
|
+
const base = resolveAppBase({ base: args.base, env });
|
|
38
|
+
|
|
39
|
+
// Positionals: link [scope] [key] OR link <scope::key>. args._[0] is the
|
|
40
|
+
// command token ('link' / 'url'), so the first argument is args._[1].
|
|
41
|
+
const first = typeof args._[1] === 'string' ? args._[1] : '';
|
|
42
|
+
const second = typeof args._[2] === 'string' ? args._[2] : '';
|
|
43
|
+
let scope = null;
|
|
44
|
+
let key = null;
|
|
45
|
+
if (first && second) {
|
|
46
|
+
// Two positionals — the first IS the scope (even one containing `::`, like
|
|
47
|
+
// `repo::owner/name`); the second is the key. The `scope::key` shorthand is
|
|
48
|
+
// only consulted for a single positional, below.
|
|
49
|
+
scope = first;
|
|
50
|
+
key = second;
|
|
51
|
+
} else if (first) {
|
|
52
|
+
// One positional: disambiguate a bare scope from the `<scope>::<key>`
|
|
53
|
+
// shorthand by scope validity, not by a naive first-`::` split — otherwise
|
|
54
|
+
// `link repo::owner/name` (a valid scope) is misread as scope="repo" + a
|
|
55
|
+
// bogus key. `scopeIssue(s) === null` is the canonical "is a valid scope".
|
|
56
|
+
const resolved = resolveScopeArg(first, (s) => scopeIssue(s) === null);
|
|
57
|
+
scope = resolved.scope;
|
|
58
|
+
key = resolved.key;
|
|
59
|
+
}
|
|
60
|
+
// `--scope` sets the scope when no positional scope was given (consistency
|
|
61
|
+
// with the other read commands); an explicit positional always wins.
|
|
62
|
+
if (!scope && typeof args.scope === 'string' && args.scope) scope = args.scope;
|
|
63
|
+
|
|
64
|
+
// Filter flags (all optional; each JSON-encoded + default-omitted downstream).
|
|
65
|
+
const q = typeof args.q === 'string' ? args.q : '';
|
|
66
|
+
const owner = parseOwnerArg(args.owner);
|
|
67
|
+
const view = parseViewArg(args.view);
|
|
68
|
+
const range = parseRangeArg(args);
|
|
69
|
+
const archived = Boolean(args.archived);
|
|
70
|
+
|
|
71
|
+
const gaveAnyInput =
|
|
72
|
+
Boolean(first) ||
|
|
73
|
+
(typeof args.scope === 'string' && Boolean(args.scope)) ||
|
|
74
|
+
Boolean(q) ||
|
|
75
|
+
owner !== 'all' ||
|
|
76
|
+
view !== 'scope' ||
|
|
77
|
+
range !== null ||
|
|
78
|
+
archived;
|
|
79
|
+
|
|
80
|
+
// Bare `lorekit link` (no scope, no lesson, no filters) → the cwd's
|
|
81
|
+
// most-specific scope, so it links to "what I'm looking at". Falls back to a
|
|
82
|
+
// bare /lore when only `global` applies.
|
|
83
|
+
if (!scope && !key && !gaveAnyInput) {
|
|
84
|
+
scope = mostSpecificScope(scopeInfo);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Assemble only the non-default params (clean URL + a truthful `--json`).
|
|
88
|
+
const params = {};
|
|
89
|
+
if (scope) params.scope = scope;
|
|
90
|
+
if (key) params.lesson = { scope, key };
|
|
91
|
+
if (q) params.q = q;
|
|
92
|
+
if (owner !== 'all') params.owner = owner;
|
|
93
|
+
if (view !== 'scope') params.view = view;
|
|
94
|
+
if (range !== null) params.range = range;
|
|
95
|
+
if (archived) params.archived = true;
|
|
96
|
+
|
|
97
|
+
// UX guard: a lesson/scope link pointing at a scope the caller isn't in
|
|
98
|
+
// (a different repo/project) may render empty for them. Note it on stderr —
|
|
99
|
+
// never stdout (keeps the URL pipeable) — and only when stderr is a TTY and
|
|
100
|
+
// we're not emitting JSON, so scripts and pipes stay quiet.
|
|
101
|
+
maybeWarnScope(scope, scopeInfo, args.json);
|
|
102
|
+
|
|
103
|
+
return emitLink({ params, base, json: args.json });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Emit a deep link: the URL alone on stdout (pipeable), or the structured
|
|
107
|
+
// `{ url, surface, base, params }` under `--json`. Returns the bounded, non-PII
|
|
108
|
+
// telemetry extras (the surface enum + booleans — never a scope string, key,
|
|
109
|
+
// query, or base URL). Shared by the `link` command AND the read commands'
|
|
110
|
+
// `--link` short-circuit so the two emit an identical shape. `params` is the
|
|
111
|
+
// already-assembled non-default param set.
|
|
112
|
+
export function emitLink({ params = {}, base, json }) {
|
|
113
|
+
const url = buildLoreUrl(params, { base });
|
|
114
|
+
const surface = surfaceFor(params);
|
|
115
|
+
if (json) {
|
|
116
|
+
log(JSON.stringify({ url, surface, base, params }, null, 2));
|
|
117
|
+
} else {
|
|
118
|
+
log(url);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
exitCode: 0,
|
|
122
|
+
'lorekit.cli.link.surface': surface,
|
|
123
|
+
'lorekit.cli.link.has_scope': Boolean(params.scope),
|
|
124
|
+
'lorekit.cli.link.has_lesson': Boolean(params.lesson),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Warn (stderr, TTY-only) when `scope` is a concrete scope the caller isn't in.
|
|
129
|
+
// `global` is always visible; a scope present in the cwd's `readOrder` is too.
|
|
130
|
+
function maybeWarnScope(scope, scopeInfo, json) {
|
|
131
|
+
if (json || !scope || scope === 'global') return;
|
|
132
|
+
if (!process.stderr.isTTY) return;
|
|
133
|
+
if ((scopeInfo.readOrder || []).includes(scope)) return;
|
|
134
|
+
err(
|
|
135
|
+
`note: ${scope} is not one of your current scopes ` +
|
|
136
|
+
`(${(scopeInfo.readOrder || []).join(', ')}); ` +
|
|
137
|
+
`the link may show nothing if you can't access that scope.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/list.mjs
CHANGED
|
@@ -14,6 +14,8 @@ import { deriveScope } from './scope.mjs';
|
|
|
14
14
|
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
16
|
import { scopeList, gather, renderSection } from './lessons-view.mjs';
|
|
17
|
+
import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
|
|
18
|
+
import { emitLink } from './link.mjs';
|
|
17
19
|
import { log, heading, c } from './util.mjs';
|
|
18
20
|
|
|
19
21
|
// Abbreviate the user's home directory to `~` for readable paths.
|
|
@@ -40,6 +42,17 @@ export async function list(args) {
|
|
|
40
42
|
// scope outside the applicable set is honoured — the user asked for it).
|
|
41
43
|
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
42
44
|
|
|
45
|
+
// `--link` short-circuits: print the Explorer deep link for the current
|
|
46
|
+
// context (the most-specific applicable scope, or `--scope`), no store reads.
|
|
47
|
+
if (args.link) {
|
|
48
|
+
const base = resolveAppBase({ base: args.base, env });
|
|
49
|
+
const scope =
|
|
50
|
+
args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
|
|
51
|
+
const params = {};
|
|
52
|
+
if (scope) params.scope = scope;
|
|
53
|
+
return emitLink({ params, base, json: args.json });
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
const { local, remote, connection } = resolveStores(root, {
|
|
44
57
|
env,
|
|
45
58
|
endpoint: args.endpoint,
|
package/src/mcp-server.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
28
28
|
import { loadControl } from './control.mjs';
|
|
29
29
|
import { createStore } from './store/index.mjs';
|
|
30
30
|
import { createRemoteStore } from './store/remote.mjs';
|
|
31
|
+
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
31
32
|
|
|
32
33
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
33
34
|
const SERVER_INFO = { name: 'lorekit-local', version: '1.0.0' };
|
|
@@ -55,6 +56,27 @@ export const MEMORY_TOOL_DEFS = [
|
|
|
55
56
|
description:
|
|
56
57
|
'Optional ISO 8601 creation date for migrating a pre-existing memory. Rejected if invalid or in the future. Applies only when the memory is first created.',
|
|
57
58
|
},
|
|
59
|
+
origin_repo: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description:
|
|
62
|
+
'Provenance: the owner/name of the repository this memory was recorded from. Derived from the working directory when omitted.',
|
|
63
|
+
},
|
|
64
|
+
origin_branch: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description:
|
|
67
|
+
'Provenance: the git branch this memory was recorded from. Derived from the working directory when omitted.',
|
|
68
|
+
},
|
|
69
|
+
origin_commit: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description:
|
|
72
|
+
'Provenance: the commit SHA checked out when this memory was recorded. Derived from the working directory when omitted.',
|
|
73
|
+
},
|
|
74
|
+
origin_pr: {
|
|
75
|
+
type: 'integer',
|
|
76
|
+
minimum: 1,
|
|
77
|
+
description:
|
|
78
|
+
'Provenance: the pull request number this memory came out of. Pass it when you know it — the server can only infer it from CI environment variables.',
|
|
79
|
+
},
|
|
58
80
|
},
|
|
59
81
|
},
|
|
60
82
|
},
|
|
@@ -148,10 +170,15 @@ export const ORG_TOOL_DEFS = [
|
|
|
148
170
|
// Legacy alias kept so existing code that imports TOOL_DEFS still compiles.
|
|
149
171
|
export const TOOL_DEFS = [...MEMORY_TOOL_DEFS, ...ORG_TOOL_DEFS];
|
|
150
172
|
|
|
151
|
-
// tool name → (store, args) → store result. The store destructures the
|
|
152
|
-
// needs, so the raw `arguments` object is passed straight through.
|
|
173
|
+
// tool name → (store, args, ctx) → store result. The store destructures the
|
|
174
|
+
// args it needs, so the raw `arguments` object is passed straight through.
|
|
175
|
+
// `ctx.root` is the resolved project root (`--dir`), NOT the process cwd — an
|
|
176
|
+
// MCP client launched from elsewhere would otherwise stamp the wrong origin.
|
|
153
177
|
const MEMORY_DISPATCH = {
|
|
154
|
-
|
|
178
|
+
// An agent calling memory.write knows the lesson, not the working directory
|
|
179
|
+
// it is running in. Fill in whatever provenance the environment can supply,
|
|
180
|
+
// with anything the caller DID pass taking precedence.
|
|
181
|
+
'memory.write': (store, a, ctx) => store.write({ ...a, ...withDerivedOrigin(a, ctx) }),
|
|
155
182
|
'memory.read': (store, a) => store.read(a),
|
|
156
183
|
'memory.list': (store, a) => store.list(a),
|
|
157
184
|
'memory.search': (store, a) => store.search(a),
|
|
@@ -159,6 +186,22 @@ const MEMORY_DISPATCH = {
|
|
|
159
186
|
'memory.archive': (store, a) => store.archive(a),
|
|
160
187
|
};
|
|
161
188
|
|
|
189
|
+
// Provenance for a tool call: the caller's explicit values win, the working
|
|
190
|
+
// directory and CI environment fill the rest. Best-effort — a failure to shell
|
|
191
|
+
// out to git must never fail the write, so it degrades to no origin at all.
|
|
192
|
+
function withDerivedOrigin(args = {}, { root } = {}) {
|
|
193
|
+
try {
|
|
194
|
+
return mergeOrigin(deriveOrigin({ cwd: root }), {
|
|
195
|
+
origin_repo: args.origin_repo ?? null,
|
|
196
|
+
origin_branch: args.origin_branch ?? null,
|
|
197
|
+
origin_commit: args.origin_commit ?? null,
|
|
198
|
+
origin_pr: args.origin_pr ?? null,
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
162
205
|
// org.* dispatch — always routed to the remote store.
|
|
163
206
|
const ORG_DISPATCH = {
|
|
164
207
|
'org.create': (remote, a) => remote.orgCreate(a),
|
|
@@ -188,7 +231,7 @@ function toolResult(id, payload) {
|
|
|
188
231
|
|
|
189
232
|
// Build the per-message handler over a resolved control model. `store` is null
|
|
190
233
|
// when mode is `off`. Org tools are always advertised regardless of mode.
|
|
191
|
-
export function createHandler(control) {
|
|
234
|
+
export function createHandler(control, { root = process.cwd() } = {}) {
|
|
192
235
|
const store = createStore(control);
|
|
193
236
|
const memoryTools = store ? MEMORY_TOOL_DEFS : [];
|
|
194
237
|
|
|
@@ -257,7 +300,7 @@ export function createHandler(control) {
|
|
|
257
300
|
const fn = MEMORY_DISPATCH[name];
|
|
258
301
|
if (!fn) return errorReply(id, -32601, `Unknown tool: ${name}`);
|
|
259
302
|
|
|
260
|
-
const result = await fn(store, args);
|
|
303
|
+
const result = await fn(store, args, { root });
|
|
261
304
|
return toolResult(id, result);
|
|
262
305
|
}
|
|
263
306
|
|
|
@@ -359,7 +402,7 @@ export async function mcpServer(
|
|
|
359
402
|
) {
|
|
360
403
|
const root = resolveProjectRoot(args.dir);
|
|
361
404
|
const control = loadControl(root, { env: withOverrides(args, env) });
|
|
362
|
-
const handle = createHandler(control);
|
|
405
|
+
const handle = createHandler(control, { root });
|
|
363
406
|
// A human who runs `lorekit mcp` in a terminal would otherwise see a silent
|
|
364
407
|
// hang with no sign it is alive. Reassure them on stderr — but only when
|
|
365
408
|
// stdin is a TTY, so a piped MCP client never sees the banner on either channel.
|
package/src/origin.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Derive a memory's ORIGIN — where a lesson is being recorded FROM — from the
|
|
2
|
+
// git working directory and the ambient CI environment.
|
|
3
|
+
//
|
|
4
|
+
// This is the counterpart to `scope.mjs`'s `deriveScope()`. A scope says where
|
|
5
|
+
// a lesson APPLIES; an origin says where it was written: the repository, the
|
|
6
|
+
// branch, the checked-out commit, and the pull request the work belonged to.
|
|
7
|
+
// The dashboard renders it as a "recorded from" block with links straight back
|
|
8
|
+
// to the PR, branch, and commit (migration 00048).
|
|
9
|
+
//
|
|
10
|
+
// Nothing here is required: every field independently degrades to `null` when
|
|
11
|
+
// it cannot be determined (no git, detached HEAD, no PR context), and a write
|
|
12
|
+
// with no origin at all behaves exactly as it did before this existed.
|
|
13
|
+
//
|
|
14
|
+
// Zero-dependency, and deliberately injectable (`{ cwd, env, run }`) so the
|
|
15
|
+
// whole derivation is testable without a real repository or a real CI runner.
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { ownerRepoFromRemote } from './scope.mjs';
|
|
18
|
+
|
|
19
|
+
function gitRunner(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync('git', args, {
|
|
22
|
+
cwd,
|
|
23
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
}).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract a pull request number from the ambient environment.
|
|
33
|
+
*
|
|
34
|
+
* Precedence, first match wins:
|
|
35
|
+
* 1. LOREKIT_PR — the explicit escape hatch, any CI or none.
|
|
36
|
+
* 2. GITHUB_REF — `refs/pull/<n>/merge` on a GitHub Actions
|
|
37
|
+
* `pull_request` run.
|
|
38
|
+
* 3. GITHUB_PR_NUMBER — set by several popular actions.
|
|
39
|
+
*
|
|
40
|
+
* Returns a positive integer, or `null` when no PR context is present.
|
|
41
|
+
*/
|
|
42
|
+
export function prNumberFromEnv(env = process.env) {
|
|
43
|
+
const explicit = toPositiveInt(env.LOREKIT_PR);
|
|
44
|
+
if (explicit !== null) return explicit;
|
|
45
|
+
|
|
46
|
+
const ref = typeof env.GITHUB_REF === 'string' ? env.GITHUB_REF : '';
|
|
47
|
+
const m = /^refs\/pull\/(\d+)\//.exec(ref);
|
|
48
|
+
if (m) {
|
|
49
|
+
const n = toPositiveInt(m[1]);
|
|
50
|
+
if (n !== null) return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return toPositiveInt(env.GITHUB_PR_NUMBER);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toPositiveInt(raw) {
|
|
57
|
+
if (raw === undefined || raw === null || raw === '') return null;
|
|
58
|
+
const n = Number(raw);
|
|
59
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether a string is a usable git ref name, per git-check-ref-format.
|
|
65
|
+
*
|
|
66
|
+
* A deny list, deliberately: a branch is whatever a contributor named it, and
|
|
67
|
+
* `feat/add+x` / `fix/issue#123` / `feat/café` are all legal. The canonical
|
|
68
|
+
* rule lives in `packages/mcp-core/src/origin.ts` (`parseOriginBranch`); this
|
|
69
|
+
* is the zero-dependency CLI copy, kept behaviourally identical so a branch the
|
|
70
|
+
* CLI derives is never one the server would reject.
|
|
71
|
+
*/
|
|
72
|
+
export function isValidRef(value) {
|
|
73
|
+
if (typeof value !== 'string' || value === '') return false;
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
if (/[\u0000-\u001f\u007f ~^:?*[\\]/.test(value)) return false;
|
|
76
|
+
if (value.startsWith('/') || value.endsWith('/')) return false;
|
|
77
|
+
if (value.startsWith('.') || value.endsWith('.')) return false;
|
|
78
|
+
if (value.endsWith('.lock')) return false;
|
|
79
|
+
if (value.includes('..') || value.includes('//') || value.includes('@{')) return false;
|
|
80
|
+
return value.length <= 255;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function firstNonEmpty(...values) {
|
|
84
|
+
for (const v of values) {
|
|
85
|
+
if (typeof v === 'string' && v.trim() !== '') return v.trim();
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive `{ origin_repo, origin_branch, origin_commit, origin_pr }` for the
|
|
92
|
+
* current working directory.
|
|
93
|
+
*
|
|
94
|
+
* Branch resolution prefers `GITHUB_HEAD_REF` over `git rev-parse`: on a
|
|
95
|
+
* GitHub Actions `pull_request` run the checkout is a detached merge commit,
|
|
96
|
+
* so git reports `HEAD` while `GITHUB_HEAD_REF` holds the real source branch.
|
|
97
|
+
*
|
|
98
|
+
* The branch is NOT lowercased (unlike a `branch::` scope) so the GitHub
|
|
99
|
+
* `/tree/` link the dashboard builds from it resolves for a mixed-case branch.
|
|
100
|
+
*
|
|
101
|
+
* @returns an object whose four fields are each a value or `null`.
|
|
102
|
+
*/
|
|
103
|
+
export function deriveOrigin({ cwd = process.cwd(), env = process.env, run = gitRunner } = {}) {
|
|
104
|
+
const remote = run(['config', '--get', 'remote.origin.url'], cwd);
|
|
105
|
+
const gitBranch = run(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
106
|
+
|
|
107
|
+
// On a GitHub Actions `pull_request` run the checkout is a DETACHED MERGE
|
|
108
|
+
// COMMIT of the head branch into the base, so `HEAD` (and `GITHUB_SHA`) name
|
|
109
|
+
// a commit that exists on neither branch. Since the branch below deliberately
|
|
110
|
+
// reports `GITHUB_HEAD_REF` — the real source branch — taking the merge SHA
|
|
111
|
+
// here would have the two fields describe different refs.
|
|
112
|
+
//
|
|
113
|
+
// The head commit is the merge commit's SECOND parent. A shallow checkout
|
|
114
|
+
// (actions/checkout's default `fetch-depth: 1`) does not have it, in which
|
|
115
|
+
// case we record NO commit: an absent field is honest, a mismatched one is
|
|
116
|
+
// worse than nothing.
|
|
117
|
+
const onMergeCheckout = Boolean(firstNonEmpty(env.GITHUB_HEAD_REF));
|
|
118
|
+
const gitCommit = onMergeCheckout
|
|
119
|
+
? run(['rev-parse', 'HEAD^2'], cwd)
|
|
120
|
+
: run(['rev-parse', 'HEAD'], cwd);
|
|
121
|
+
|
|
122
|
+
// Precedence matches every other field: the explicit `LOREKIT_*` override
|
|
123
|
+
// first, then what the environment can work out. `LOREKIT_REPO` losing to the
|
|
124
|
+
// git remote would make it the one override that does not override.
|
|
125
|
+
//
|
|
126
|
+
// Each candidate goes through `isValidRepo` individually rather than only the
|
|
127
|
+
// winner, so an odd remote FALLS THROUGH to `GITHUB_REPOSITORY` instead of
|
|
128
|
+
// shadowing it with a value the server's strict `parseOriginRepo` rejects —
|
|
129
|
+
// which would 400 the write this provenance only decorates.
|
|
130
|
+
const repo =
|
|
131
|
+
isValidRepo(env.LOREKIT_REPO) ??
|
|
132
|
+
isValidRepo(ownerRepoFromRemote(remote)) ??
|
|
133
|
+
isValidRepo(env.GITHUB_REPOSITORY);
|
|
134
|
+
const branchRaw = firstNonEmpty(env.LOREKIT_BRANCH, env.GITHUB_HEAD_REF, gitBranch);
|
|
135
|
+
// A derived branch that the server would reject is dropped here rather than
|
|
136
|
+
// sent: provenance is decoration, and it must never fail the write it is
|
|
137
|
+
// decorating.
|
|
138
|
+
const branch = branchRaw && branchRaw !== 'HEAD' && isValidRef(branchRaw) ? branchRaw : null;
|
|
139
|
+
// GITHUB_SHA is only a safe fallback OFF a merge checkout — on one it is the
|
|
140
|
+
// merge commit, the very value the branch above exists to avoid.
|
|
141
|
+
const commitRaw = onMergeCheckout
|
|
142
|
+
? firstNonEmpty(env.LOREKIT_COMMIT, gitCommit)
|
|
143
|
+
: firstNonEmpty(env.LOREKIT_COMMIT, gitCommit, env.GITHUB_SHA);
|
|
144
|
+
const commit = commitRaw && /^[0-9a-fA-F]{7,40}$/.test(commitRaw) ? commitRaw.toLowerCase() : null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
origin_repo: repo,
|
|
148
|
+
origin_branch: branch,
|
|
149
|
+
origin_commit: commit,
|
|
150
|
+
origin_pr: prNumberFromEnv(env),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normalise a candidate `owner/name`, or null when it is not one.
|
|
156
|
+
*
|
|
157
|
+
* The single repo rule for every derivation path, matching the server's
|
|
158
|
+
* `parseOriginRepo` (`packages/mcp-core/src/origin.ts`) — including its
|
|
159
|
+
* rejection of a dots-only segment, which `[\\w.-]+` alone admits and which
|
|
160
|
+
* would render as a link to a different repository.
|
|
161
|
+
*/
|
|
162
|
+
export function isValidRepo(value) {
|
|
163
|
+
if (typeof value !== 'string') return null;
|
|
164
|
+
const normalized = value.trim().toLowerCase();
|
|
165
|
+
if (normalized === '' || normalized.length > 140) return null;
|
|
166
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(normalized)) return null;
|
|
167
|
+
if (normalized.split('/').some((segment) => /^\.+$/.test(segment))) return null;
|
|
168
|
+
return normalized;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Merge a derived origin under caller-supplied overrides, dropping null fields.
|
|
173
|
+
*
|
|
174
|
+
* An explicitly supplied value always wins; a field neither supplied nor
|
|
175
|
+
* derivable is omitted entirely rather than sent as `null`, so the server's
|
|
176
|
+
* "keep the last KNOWN origin" upsert rule never erases a previously recorded
|
|
177
|
+
* value.
|
|
178
|
+
*/
|
|
179
|
+
export function mergeOrigin(derived = {}, overrides = {}) {
|
|
180
|
+
const out = {};
|
|
181
|
+
for (const field of ['origin_repo', 'origin_branch', 'origin_commit', 'origin_pr']) {
|
|
182
|
+
const value = overrides[field] ?? derived[field] ?? null;
|
|
183
|
+
if (value !== null && value !== undefined && value !== '') out[field] = value;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
package/src/search.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import { deriveScope } from './scope.mjs';
|
|
|
19
19
|
import { resolveDenies } from './control.mjs';
|
|
20
20
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
21
|
import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
|
|
22
|
+
import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
|
|
23
|
+
import { emitLink } from './link.mjs';
|
|
22
24
|
import { log, err, heading, c } from './util.mjs';
|
|
23
25
|
|
|
24
26
|
export async function search(args) {
|
|
@@ -41,6 +43,17 @@ export async function search(args) {
|
|
|
41
43
|
// scope outside the applicable set is honoured — the user asked for it).
|
|
42
44
|
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
43
45
|
|
|
46
|
+
// `--link` short-circuits: print the Explorer deep link for this search
|
|
47
|
+
// (`?q=…` plus the most-specific applicable scope), without touching a store.
|
|
48
|
+
if (args.link) {
|
|
49
|
+
const base = resolveAppBase({ base: args.base, env });
|
|
50
|
+
const scope =
|
|
51
|
+
args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
|
|
52
|
+
const params = { q: query };
|
|
53
|
+
if (scope) params.scope = scope;
|
|
54
|
+
return emitLink({ params, base, json: args.json });
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const { local, remote, connection } = resolveStores(root, {
|
|
45
58
|
env,
|
|
46
59
|
endpoint: args.endpoint,
|
package/src/show.mjs
CHANGED
|
@@ -19,6 +19,8 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
19
19
|
import { resolveDenies } from './control.mjs';
|
|
20
20
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
21
|
import { normalizeEntry, shortDate, describeError, recordsDiverge, parseScopeKey } from './lessons-view.mjs';
|
|
22
|
+
import { resolveAppBase } from './deeplink-pure.mjs';
|
|
23
|
+
import { emitLink } from './link.mjs';
|
|
22
24
|
import { log, err, heading, status, c } from './util.mjs';
|
|
23
25
|
|
|
24
26
|
// Read one scope::key from a store, normalizing the result into a small,
|
|
@@ -67,6 +69,13 @@ export async function show(args) {
|
|
|
67
69
|
return 1;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// `--link` short-circuits: print the deep link that opens THIS lesson's detail
|
|
73
|
+
// sheet (`?scope=…&lesson=…`) for the current args, without touching a store.
|
|
74
|
+
if (args.link) {
|
|
75
|
+
const base = resolveAppBase({ base: args.base, env });
|
|
76
|
+
return emitLink({ params: { scope, lesson: { scope, key } }, base, json: args.json });
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
const { local, remote, connection } = resolveStores(root, {
|
|
71
80
|
env,
|
|
72
81
|
endpoint: args.endpoint,
|
package/src/store/format.mjs
CHANGED
|
@@ -17,6 +17,13 @@ export const FIELDS = [
|
|
|
17
17
|
'created',
|
|
18
18
|
'updated',
|
|
19
19
|
'archived_at',
|
|
20
|
+
// Provenance — where the lesson was recorded FROM (see src/origin.mjs).
|
|
21
|
+
// Appended, never reordered: parseEntry is tolerant, so a file written before
|
|
22
|
+
// these existed simply decodes them as absent.
|
|
23
|
+
'origin_repo',
|
|
24
|
+
'origin_branch',
|
|
25
|
+
'origin_commit',
|
|
26
|
+
'origin_pr',
|
|
20
27
|
];
|
|
21
28
|
|
|
22
29
|
// Serialize an entry ({ ...columns, value }) into file text.
|
package/src/store/local.mjs
CHANGED
|
@@ -91,7 +91,10 @@ class LocalStore {
|
|
|
91
91
|
// existing key (a creation date never moves). Returns { ok:false, error } on
|
|
92
92
|
// an invalid or future-dated value rather than throwing, matching the store
|
|
93
93
|
// contract's error surfacing.
|
|
94
|
-
async write({
|
|
94
|
+
async write({
|
|
95
|
+
scope, key, value, tags, source_agent, trigger, created_at,
|
|
96
|
+
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
97
|
+
} = {}) {
|
|
95
98
|
let override;
|
|
96
99
|
try {
|
|
97
100
|
override = normalizeCreatedAt(created_at);
|
|
@@ -109,6 +112,13 @@ class LocalStore {
|
|
|
109
112
|
tags: Array.isArray(tags) ? tags : [],
|
|
110
113
|
source_agent: source_agent || null,
|
|
111
114
|
trigger: trigger || null,
|
|
115
|
+
// Provenance keeps the last KNOWN value per field, mirroring the hosted
|
|
116
|
+
// memory_write upsert: a write that does not know a field must not erase
|
|
117
|
+
// what a previous write recorded.
|
|
118
|
+
origin_repo: origin_repo ?? existing?.entry.origin_repo ?? null,
|
|
119
|
+
origin_branch: origin_branch ?? existing?.entry.origin_branch ?? null,
|
|
120
|
+
origin_commit: origin_commit ?? existing?.entry.origin_commit ?? null,
|
|
121
|
+
origin_pr: origin_pr ?? existing?.entry.origin_pr ?? null,
|
|
112
122
|
created,
|
|
113
123
|
updated: existing ? now : override || now,
|
|
114
124
|
archived_at: null,
|
|
@@ -135,6 +145,10 @@ class LocalStore {
|
|
|
135
145
|
tags: Array.isArray(entry.tags) ? entry.tags : [],
|
|
136
146
|
source_agent: entry.source_agent ?? null,
|
|
137
147
|
trigger: entry.trigger ?? null,
|
|
148
|
+
origin_repo: entry.origin_repo ?? null,
|
|
149
|
+
origin_branch: entry.origin_branch ?? null,
|
|
150
|
+
origin_commit: entry.origin_commit ?? null,
|
|
151
|
+
origin_pr: entry.origin_pr ?? null,
|
|
138
152
|
created: entry.created ?? now,
|
|
139
153
|
updated: entry.updated ?? now,
|
|
140
154
|
archived_at: entry.archived_at ?? null,
|
package/src/store/remote.mjs
CHANGED
|
@@ -91,7 +91,10 @@ class RemoteStore {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
async write(args = {}) {
|
|
94
|
-
const {
|
|
94
|
+
const {
|
|
95
|
+
scope, key, value, tags, source_agent, trigger, org, ttl_days, clear_ttl, created_at,
|
|
96
|
+
origin_repo, origin_branch, origin_commit, origin_pr,
|
|
97
|
+
} = args;
|
|
95
98
|
const body = { scope, key, value };
|
|
96
99
|
if (tags !== undefined) body.tags = tags;
|
|
97
100
|
if (source_agent !== undefined) body.source_agent = source_agent;
|
|
@@ -100,6 +103,13 @@ class RemoteStore {
|
|
|
100
103
|
if (ttl_days !== undefined) body.ttl_days = ttl_days;
|
|
101
104
|
if (clear_ttl !== undefined) body.clear_ttl = clear_ttl;
|
|
102
105
|
if (created_at !== undefined) body.created_at = created_at;
|
|
106
|
+
// Provenance — only sent when known. Omitting a field leaves whatever the
|
|
107
|
+
// row already recorded intact (the RPC coalesces), which is what makes a
|
|
108
|
+
// write from a machine with no git context non-destructive.
|
|
109
|
+
if (origin_repo !== undefined) body.origin_repo = origin_repo;
|
|
110
|
+
if (origin_branch !== undefined) body.origin_branch = origin_branch;
|
|
111
|
+
if (origin_commit !== undefined) body.origin_commit = origin_commit;
|
|
112
|
+
if (origin_pr !== undefined) body.origin_pr = origin_pr;
|
|
103
113
|
const res = await this._rest('/memories', { method: 'POST', body });
|
|
104
114
|
return { ok: res.ok, error: res.error, networkError: res.networkError };
|
|
105
115
|
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
|
|
6
6
|
// packages. One span + one counter data point per human-facing command
|
|
7
7
|
// (install / uninstall / doctor / list / search / show / stats / scopes / diff /
|
|
8
|
-
// tree / lint / dedupe / migrate), fired to Dash0 so the maintainers can
|
|
9
|
-
// which commands people actually run.
|
|
8
|
+
// tree / lint / dedupe / link / migrate), fired to Dash0 so the maintainers can
|
|
9
|
+
// see which commands people actually run.
|
|
10
10
|
//
|
|
11
11
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
12
12
|
// • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
|
|
@@ -38,7 +38,7 @@ const DEFAULT_DATASET = 'default';
|
|
|
38
38
|
|
|
39
39
|
// Flags worth counting (e.g. how many installs are --global). Bounded on
|
|
40
40
|
// purpose: only these booleans are ever attached, never free-form values.
|
|
41
|
-
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json'];
|
|
41
|
+
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json', 'link'];
|
|
42
42
|
|
|
43
43
|
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
44
44
|
|
|
@@ -364,7 +364,7 @@ function normalizeExitCode(result) {
|
|
|
364
364
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
365
365
|
* are swallowed — the command result is never affected.
|
|
366
366
|
*
|
|
367
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | migrate
|
|
367
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
|
|
368
368
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
369
369
|
* @param {string} version CLI version (from package.json)
|
|
370
370
|
* @param {() => Promise<number>} run the command handler
|
package/src/tree.mjs
CHANGED
|
@@ -27,6 +27,8 @@ import { resolveDenies } from './control.mjs';
|
|
|
27
27
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
28
28
|
import { resolvePrecedence } from './lessons-pure.mjs';
|
|
29
29
|
import { gather, preview, shortDate } from './lessons-view.mjs';
|
|
30
|
+
import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
|
|
31
|
+
import { emitLink } from './link.mjs';
|
|
30
32
|
import { log, heading, status, c } from './util.mjs';
|
|
31
33
|
|
|
32
34
|
export async function tree(args) {
|
|
@@ -39,6 +41,17 @@ export async function tree(args) {
|
|
|
39
41
|
// `--scope <s>` narrows to one (honored even outside the set — the user asked).
|
|
40
42
|
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
|
|
41
43
|
|
|
44
|
+
// `--link` short-circuits: print the Explorer deep link for the resolved
|
|
45
|
+
// context (the most-specific applicable scope, or `--scope`), no store reads.
|
|
46
|
+
if (args.link) {
|
|
47
|
+
const base = resolveAppBase({ base: args.base, env });
|
|
48
|
+
const scope =
|
|
49
|
+
args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
|
|
50
|
+
const params = {};
|
|
51
|
+
if (scope) params.scope = scope;
|
|
52
|
+
return emitLink({ params, base, json: args.json });
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
const { local, remote, connection } = resolveStores(root, {
|
|
43
56
|
env,
|
|
44
57
|
endpoint: args.endpoint,
|