graphlin 0.1.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/.claude-plugin/plugin.json +12 -0
- package/.codex-plugin/plugin.json +29 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/adapters/README.md +32 -0
- package/adapters/claude/hooks.json +10 -0
- package/adapters/claude/profile.json +18 -0
- package/adapters/codex/hooks.json +9 -0
- package/adapters/codex/profile.json +22 -0
- package/adapters/kiro/profile.json +8 -0
- package/mcp.json +11 -0
- package/package.json +114 -0
- package/plugin.json +20 -0
- package/runtime/collector/index.mjs +23 -0
- package/runtime/core/candidates.mjs +300 -0
- package/runtime/core/common.mjs +69 -0
- package/runtime/core/evidence.mjs +150 -0
- package/runtime/core/graph.mjs +398 -0
- package/runtime/core/index.mjs +4 -0
- package/runtime/core/lexical.mjs +255 -0
- package/runtime/core/privacy.mjs +206 -0
- package/runtime/core/tool-discovery.mjs +122 -0
- package/runtime/daemon/auth.mjs +50 -0
- package/runtime/daemon/connection-info.mjs +249 -0
- package/runtime/daemon/demo.mjs +195 -0
- package/runtime/daemon/diagnostics.mjs +404 -0
- package/runtime/daemon/export.mjs +7 -0
- package/runtime/daemon/ipc.mjs +28 -0
- package/runtime/daemon/lock.mjs +137 -0
- package/runtime/daemon/manager.mjs +320 -0
- package/runtime/daemon/paths.mjs +108 -0
- package/runtime/daemon/persistence.mjs +64 -0
- package/runtime/daemon/server.mjs +292 -0
- package/runtime/daemon/settings.mjs +103 -0
- package/runtime/jev/fixture.mjs +99 -0
- package/runtime/jev/index.mjs +784 -0
- package/runtime/jev/questions.mjs +268 -0
- package/runtime/jev/wire.mjs +152 -0
- package/runtime/pipeline.mjs +1071 -0
- package/runtime/web/app.js +2596 -0
- package/runtime/web/index.html +265 -0
- package/runtime/web/layout.js +336 -0
- package/runtime/web/sidebar.js +525 -0
- package/runtime/web/sketch.js +347 -0
- package/runtime/web/style.css +593 -0
- package/schemas/bundle.schema.json +243 -0
- package/schemas/event.schema.json +108 -0
- package/schemas/graph.schema.json +449 -0
- package/schemas/patch.schema.json +111 -0
- package/scripts/arguments.mjs +37 -0
- package/scripts/build-packages.mjs +160 -0
- package/scripts/collect.sh +23 -0
- package/scripts/collector.mjs +11 -0
- package/scripts/control.mjs +80 -0
- package/scripts/daemon.mjs +28 -0
- package/scripts/graphlin.mjs +112 -0
- package/scripts/onboarding.mjs +413 -0
- package/scripts/validate-packages.mjs +118 -0
- package/skills/graphlin/SKILL.md +103 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface, emitKeypressEvents } from 'node:readline';
|
|
3
|
+
import { readFile, lstat, open } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { buildPackages } from './build-packages.mjs';
|
|
8
|
+
import { validatePackage } from './validate-packages.mjs';
|
|
9
|
+
import { projectPaths, privateDirectory, atomicJSON, readPrivateJSON } from '../runtime/daemon/paths.mjs';
|
|
10
|
+
|
|
11
|
+
const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
12
|
+
const HOSTS = ['claude', 'codex'];
|
|
13
|
+
const PLUGIN = 'graphlin@graphlin-local';
|
|
14
|
+
const defaults = { allowSource: false, persistEvidence: false, displayEvidence: true };
|
|
15
|
+
const settingsAPI = () => import('../runtime/daemon/settings.mjs');
|
|
16
|
+
const fail = (code, message) => Object.assign(new Error(message), { onboarding: true, code });
|
|
17
|
+
const cancelled = () => fail('cancelled', 'Setup cancelled. Any completed host changes remain recorded; run init again to continue.');
|
|
18
|
+
const quote = value => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
19
|
+
|
|
20
|
+
// Host output is intentionally not relayed: it can contain unrelated local
|
|
21
|
+
// configuration. Errors report the attempted operation, never raw stderr.
|
|
22
|
+
export function runHost(command, args, { cwd, env = process.env, signal, timeout = 30_000, capture = false } = {}) {
|
|
23
|
+
const childEnv = { ...env };
|
|
24
|
+
delete childEnv.TYPESAFE_API_KEY;
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (signal?.aborted) { reject(cancelled()); return; }
|
|
27
|
+
const child = spawn(command, args, { cwd, env: childEnv, shell: false,
|
|
28
|
+
stdio: ['ignore', capture ? 'pipe' : 'ignore', 'ignore'] });
|
|
29
|
+
let finished = false, timer, escalation, reason, output = '', bytes = 0;
|
|
30
|
+
const finish = error => {
|
|
31
|
+
if (finished) return;
|
|
32
|
+
finished = true;
|
|
33
|
+
clearTimeout(timer); clearTimeout(escalation);
|
|
34
|
+
signal?.removeEventListener('abort', abort);
|
|
35
|
+
child.stdout?.destroy();
|
|
36
|
+
if (error) reject(error); else resolve(capture ? output : undefined);
|
|
37
|
+
};
|
|
38
|
+
const stop = error => {
|
|
39
|
+
if (reason || finished) return;
|
|
40
|
+
reason = error;
|
|
41
|
+
child.kill('SIGTERM');
|
|
42
|
+
escalation = setTimeout(() => { child.kill('SIGKILL'); finish(reason); }, 300);
|
|
43
|
+
};
|
|
44
|
+
const abort = () => stop(cancelled());
|
|
45
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
46
|
+
timer = setTimeout(() => stop(fail('host_timeout',
|
|
47
|
+
`${command} did not finish. Check its plugin setup in a terminal, then retry Graphlin init or uninstall.`)), timeout);
|
|
48
|
+
child.stdout?.on('data', chunk => {
|
|
49
|
+
bytes += chunk.length;
|
|
50
|
+
if (bytes > 256 * 1024) stop(fail('host_output_limit', 'Host plugin metadata exceeded the safe limit. Review its marketplace configuration in your terminal.'));
|
|
51
|
+
else output += chunk.toString('utf8');
|
|
52
|
+
});
|
|
53
|
+
child.stdout?.on('error', () => stop(fail('host_unavailable', 'Could not read host plugin metadata. Retry setup.')));
|
|
54
|
+
child.once('error', () => finish(fail('host_unavailable',
|
|
55
|
+
`Could not run ${command}. Install its CLI and make it available on PATH, then retry.`)));
|
|
56
|
+
child.once('close', (code, childSignal) => finish(reason || (code === 0 ? undefined :
|
|
57
|
+
fail('host_failed', `${HOSTS.includes(command) ? `${command} ${args.slice(0, 3).join(' ')}` : 'Launcher'} ${childSignal ? 'was interrupted' : `failed (exit ${code})`}. Review that host's plugin setup, then retry Graphlin init or uninstall.`))));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function detectHosts({ run = runHost, ...context } = {}) {
|
|
62
|
+
const result = await Promise.all(HOSTS.map(async host => {
|
|
63
|
+
try { await run(host, ['--version'], { ...context, timeout: 1500 }); return host; }
|
|
64
|
+
catch (error) { if (context.signal?.aborted) throw cancelled(); return null; }
|
|
65
|
+
}));
|
|
66
|
+
return result.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function terminalPrompt(label, { secret = false, input = process.stdin, output = process.stderr, signal } = {}) {
|
|
70
|
+
if (!input.isTTY || !output.isTTY) return Promise.reject(fail('terminal_required',
|
|
71
|
+
'Run graphlin init in an interactive terminal. Keys are accepted only at its masked prompt or through TYPESAFE_API_KEY.'));
|
|
72
|
+
if (signal?.aborted) return Promise.reject(cancelled());
|
|
73
|
+
if (!secret) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const reader = createInterface({ input, output, terminal: true });
|
|
76
|
+
let answered = false;
|
|
77
|
+
const abort = () => reader.close();
|
|
78
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
79
|
+
reader.once('SIGINT', abort);
|
|
80
|
+
reader.once('close', () => {
|
|
81
|
+
signal?.removeEventListener('abort', abort);
|
|
82
|
+
if (!answered) reject(cancelled());
|
|
83
|
+
});
|
|
84
|
+
reader.question(label, answer => { answered = true; reader.close(); resolve(answer.trim()); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let value = '', done = false;
|
|
89
|
+
const wasRaw = input.isRaw, wasPaused = input.isPaused();
|
|
90
|
+
const finish = error => {
|
|
91
|
+
if (done) return;
|
|
92
|
+
done = true;
|
|
93
|
+
input.removeListener('keypress', keypress);
|
|
94
|
+
input.removeListener('end', abort);
|
|
95
|
+
signal?.removeEventListener('abort', abort);
|
|
96
|
+
input.setRawMode(Boolean(wasRaw));
|
|
97
|
+
if (wasPaused) input.pause();
|
|
98
|
+
output.write('\n');
|
|
99
|
+
if (error) { value = ''; reject(error); } else resolve(value);
|
|
100
|
+
};
|
|
101
|
+
const abort = () => finish(cancelled());
|
|
102
|
+
const keypress = (text, key = {}) => {
|
|
103
|
+
if (key.ctrl && ['c', 'd'].includes(key.name)) { abort(); return; }
|
|
104
|
+
if (['return', 'enter'].includes(key.name)) { finish(); return; }
|
|
105
|
+
if (key.name === 'backspace') {
|
|
106
|
+
if (value) { value = value.slice(0, -1); output.write('\b \b'); }
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!key.ctrl && !key.meta && text && /^[\x21-\x7e]+$/.test(text) && value.length + text.length <= 4096) {
|
|
110
|
+
value += text; output.write('*'.repeat(text.length));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
emitKeypressEvents(input);
|
|
114
|
+
input.setRawMode(true);
|
|
115
|
+
input.on('keypress', keypress);
|
|
116
|
+
input.once('end', abort);
|
|
117
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
118
|
+
output.write(label);
|
|
119
|
+
input.resume();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function choose(label, choices, prompt) {
|
|
124
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
125
|
+
const value = (await prompt(label)).toLowerCase();
|
|
126
|
+
if (choices.includes(value)) return value;
|
|
127
|
+
}
|
|
128
|
+
throw fail('invalid_choice', `Choose ${choices.join(', ')}. Run graphlin init again.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function packageVersion() {
|
|
132
|
+
const { version } = JSON.parse(await readFile(path.join(ROOT, 'package.json'), 'utf8'));
|
|
133
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) throw fail('invalid_version', 'Obtain a complete Graphlin distribution.');
|
|
134
|
+
return version;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function preparePackages(dataDir, version, { build = buildPackages } = {}) {
|
|
138
|
+
const outputDir = path.join(dataDir, 'plugins', 'graphlin', version);
|
|
139
|
+
// The builder rejects symlink ancestors; use that same boundary before
|
|
140
|
+
// reading a completion marker or creating the Claude marketplace.
|
|
141
|
+
let current = path.parse(outputDir).root;
|
|
142
|
+
for (const part of outputDir.slice(current.length).split(path.sep).filter(Boolean)) {
|
|
143
|
+
current = path.join(current, part);
|
|
144
|
+
const info = await lstat(current).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
145
|
+
if (info && (!info.isDirectory() || info.isSymbolicLink())) throw fail('unsafe_package_path', 'Use a private Graphlin data directory without symlinks.');
|
|
146
|
+
}
|
|
147
|
+
const marker = path.join(outputDir, '.onboarding.json');
|
|
148
|
+
let complete;
|
|
149
|
+
try { complete = await readPrivateJSON(marker, 1024); }
|
|
150
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
151
|
+
if (complete?.version === version) {
|
|
152
|
+
try {
|
|
153
|
+
for (const host of HOSTS) await validatePackage(path.join(outputDir, host, 'graphlin'));
|
|
154
|
+
const claude = await readPrivateJSON(path.join(outputDir, 'claude/.claude-plugin/marketplace.json'), 4096);
|
|
155
|
+
const codex = JSON.parse(await readFile(path.join(outputDir, 'codex/.agents/plugins/marketplace.json'), 'utf8'));
|
|
156
|
+
if (claude.name === 'graphlin-local' && claude.plugins?.length === 1 &&
|
|
157
|
+
claude.plugins[0].name === 'graphlin' && claude.plugins[0].source === './graphlin' &&
|
|
158
|
+
codex.name === 'graphlin-local' && codex.plugins?.length === 1 &&
|
|
159
|
+
codex.plugins[0].source?.path === './graphlin') return outputDir;
|
|
160
|
+
} catch { /* The builder safely recovers missing or incomplete managed files. */ }
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await privateDirectory(dataDir);
|
|
164
|
+
await build({ outputDir });
|
|
165
|
+
const marketplace = path.join(outputDir, 'claude', '.claude-plugin');
|
|
166
|
+
await privateDirectory(marketplace);
|
|
167
|
+
await atomicJSON(path.join(marketplace, 'marketplace.json'), {
|
|
168
|
+
name: 'graphlin-local',
|
|
169
|
+
owner: { name: 'Graphlin contributors' },
|
|
170
|
+
plugins: [{ name: 'graphlin', source: './graphlin', version,
|
|
171
|
+
description: 'Local architecture and activity viewer.' }],
|
|
172
|
+
});
|
|
173
|
+
await atomicJSON(marker, { version });
|
|
174
|
+
return outputDir;
|
|
175
|
+
} catch {
|
|
176
|
+
throw fail('package_build_failed',
|
|
177
|
+
'Could not prepare Graphlin plugins. Use the complete GitHub package: npx --yes --package=github:royosherove/graphlin graphlin init');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function hostList(host, kind, run, context) {
|
|
182
|
+
const output = await run(host, ['plugin', ...(kind === 'marketplace' ? ['marketplace'] : []), 'list', '--json'],
|
|
183
|
+
{ ...context, capture: true });
|
|
184
|
+
try {
|
|
185
|
+
const value = JSON.parse(output);
|
|
186
|
+
const list = host === 'claude' ? value : value[kind === 'marketplace' ? 'marketplaces' : 'installed'];
|
|
187
|
+
if (!Array.isArray(list) || list.some(item => !item || typeof item !== 'object')) throw new Error();
|
|
188
|
+
return list;
|
|
189
|
+
} catch {
|
|
190
|
+
throw fail('host_metadata_invalid', `Could not verify ${host} plugin metadata. Update its CLI and inspect plugin marketplace list --json before retrying.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read only our generated catalogue, never arbitrary host config or project
|
|
195
|
+
// source. Refuse symlinks, devices, oversized files, and marketplaces that have
|
|
196
|
+
// acquired other plugins rather than rebinding somebody else's marketplace.
|
|
197
|
+
async function verifyManagedMarketplace(root, host, paths, saved) {
|
|
198
|
+
const parent = path.join(paths.dataDir, 'plugins', 'graphlin');
|
|
199
|
+
const relative = path.relative(parent, root).split(path.sep);
|
|
200
|
+
if (relative.length !== 2 || !/^\d+\.\d+\.\d+$/.test(relative[0]) || relative[1] !== host) {
|
|
201
|
+
throw fail('marketplace_conflict', 'graphlin-local belongs to a different installation. Its configuration was left unchanged; inspect your host marketplaces before retrying.');
|
|
202
|
+
}
|
|
203
|
+
const filename = path.join(root, host === 'claude' ? '.claude-plugin/marketplace.json' : '.agents/plugins/marketplace.json');
|
|
204
|
+
let file;
|
|
205
|
+
try {
|
|
206
|
+
let directory = parent;
|
|
207
|
+
for (const part of [...relative, ...(host === 'claude' ? ['.claude-plugin'] : ['.agents', 'plugins'])]) {
|
|
208
|
+
directory = path.join(directory, part);
|
|
209
|
+
if (!(await lstat(directory)).isDirectory()) throw new Error();
|
|
210
|
+
}
|
|
211
|
+
file = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
212
|
+
const info = await file.stat();
|
|
213
|
+
if (!info.isFile() || info.size > 16 * 1024) throw new Error();
|
|
214
|
+
const bytes = Buffer.alloc(16 * 1024 + 1);
|
|
215
|
+
const { bytesRead } = await file.read(bytes);
|
|
216
|
+
const catalog = JSON.parse(bytes.subarray(0, bytesRead).toString());
|
|
217
|
+
const source = catalog.plugins?.[0]?.source;
|
|
218
|
+
if (catalog.name !== 'graphlin-local' || catalog.plugins?.length !== 1 ||
|
|
219
|
+
catalog.plugins[0].name !== 'graphlin' ||
|
|
220
|
+
(host === 'claude' ? source !== './graphlin' : source?.source !== 'local' || source.path !== './graphlin')) throw new Error();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
// A recorded installation with deleted files can still be recovered, but
|
|
223
|
+
// an existing modified/foreign catalogue cannot be silently replaced.
|
|
224
|
+
if (error.code === 'ENOENT' && saved.installation?.version === relative[0] &&
|
|
225
|
+
saved.installation.hosts.includes(host)) return;
|
|
226
|
+
throw fail('marketplace_conflict', 'The existing graphlin-local catalogue could not be verified as Graphlin-only. Inspect its host registration before retrying; no replacement was attempted.');
|
|
227
|
+
} finally { await file?.close(); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function configureMarketplace(host, outputDir, paths, saved, run, context, onRemoved) {
|
|
231
|
+
const root = path.join(outputDir, host);
|
|
232
|
+
const entries = (await hostList(host, 'marketplace', run, context)).filter(item => item.name === 'graphlin-local');
|
|
233
|
+
if (entries.length > 1) throw fail('marketplace_conflict', 'Multiple graphlin-local marketplaces are configured. Resolve them in your host before retrying.');
|
|
234
|
+
const entry = entries[0];
|
|
235
|
+
if (entry) {
|
|
236
|
+
const oldRoot = host === 'claude' && entry.source === 'directory' ? entry.path :
|
|
237
|
+
host === 'codex' && entry.marketplaceSource?.sourceType === 'local' &&
|
|
238
|
+
entry.marketplaceSource.source === entry.root ? entry.root : null;
|
|
239
|
+
if (typeof oldRoot !== 'string' || !path.isAbsolute(oldRoot)) throw fail('marketplace_conflict',
|
|
240
|
+
'graphlin-local is not a verified local marketplace. Inspect the host registration before retrying.');
|
|
241
|
+
await verifyManagedMarketplace(path.resolve(oldRoot), host, paths, saved);
|
|
242
|
+
if (path.resolve(oldRoot) === root) return;
|
|
243
|
+
if (host === 'codex') {
|
|
244
|
+
await run(host, ['plugin', 'marketplace', 'remove', 'graphlin-local'], context);
|
|
245
|
+
await onRemoved();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Claude replaces a verified same-name directory registration; Codex only
|
|
249
|
+
// permits add after a different source has been removed. Both were probed
|
|
250
|
+
// using isolated synthetic catalogues, not inferred from an error string.
|
|
251
|
+
await run(host, ['plugin', 'marketplace', 'add', root], context);
|
|
252
|
+
const registered = (await hostList(host, 'marketplace', run, context)).find(item => item.name === 'graphlin-local');
|
|
253
|
+
const registeredRoot = host === 'claude' ? registered?.path : registered?.root;
|
|
254
|
+
if (typeof registeredRoot !== 'string' || path.resolve(registeredRoot) !== root) throw fail('host_registration_failed',
|
|
255
|
+
`The ${host} marketplace did not register the requested Graphlin package. Retry init --host both after inspecting the host configuration.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function agentInstructions({ projectRoot, dataDir, hosts = HOSTS }) {
|
|
259
|
+
// Printed commands are for a second terminal; execution always uses argv.
|
|
260
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(projectRoot + dataDir)) {
|
|
261
|
+
return 'Start your chosen agent in the same project with GRAPHLIN_DATA_DIR set to the same data directory. Review and trust Graphlin in the host; use /hooks in Codex.\n';
|
|
262
|
+
}
|
|
263
|
+
return 'Keep Graphlin running. In a second terminal, start a new agent session:\n' +
|
|
264
|
+
hosts.map(host => ` cd ${quote(projectRoot)} && GRAPHLIN_DATA_DIR=${quote(dataDir)} ${host}\n` +
|
|
265
|
+
(host === 'claude' ? ' Claude: accept the project trust prompt; use /plugin to confirm Graphlin is enabled.\n' :
|
|
266
|
+
' Codex: accept project trust, then use /hooks to review and trust Graphlin hooks.\n')).join('') +
|
|
267
|
+
'Ask: “Orient yourself in this project and explain how its components connect.”\n' +
|
|
268
|
+
'Installation does not verify hook activation. Use graphlin doctor and the viewer hook feed to diagnose missing events.\n';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function needsOnboarding(options, { readSettings, version = packageVersion, inspect } = {}) {
|
|
272
|
+
readSettings ??= (await settingsAPI()).readSettings;
|
|
273
|
+
const saved = await readSettings(options);
|
|
274
|
+
if (!saved.policy || !saved.installation?.hosts?.length || saved.installation.pendingHosts?.length ||
|
|
275
|
+
saved.installation.version !== await version() ||
|
|
276
|
+
((options.allowSource ?? saved.policy.allowSource) && !(process.env.TYPESAFE_API_KEY ?? saved.apiKey)) ||
|
|
277
|
+
options.host !== undefined) return true;
|
|
278
|
+
inspect ??= (await import('../runtime/daemon/connection-info.mjs')).inspectInstalledPackages;
|
|
279
|
+
const paths = await projectPaths(options.projectRoot, options.dataDir);
|
|
280
|
+
const packages = await inspect({ dataDir: paths.dataDir, version: saved.installation.version });
|
|
281
|
+
return saved.installation.hosts.some(host => !packages[host]);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function initOnboarding(options, dependencies = {}) {
|
|
285
|
+
const { readSettings, saveSettings } = dependencies.settings ?? await settingsAPI();
|
|
286
|
+
const write = dependencies.write ?? (text => process.stderr.write(text));
|
|
287
|
+
const interactive = dependencies.interactive ?? Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
288
|
+
const prompt = dependencies.prompt ?? ((label, extra) => terminalPrompt(label, { ...extra, signal: options.signal }));
|
|
289
|
+
const env = dependencies.env ?? process.env;
|
|
290
|
+
const run = dependencies.run ?? runHost;
|
|
291
|
+
const paths = await projectPaths(options.projectRoot, options.dataDir);
|
|
292
|
+
const saved = await readSettings(paths);
|
|
293
|
+
const previousPending = saved.installation?.pendingHosts ?? [];
|
|
294
|
+
const resuming = previousPending.length > 0 && options.host === undefined;
|
|
295
|
+
if (options.replaceKey && !interactive) throw fail('terminal_required',
|
|
296
|
+
'Replacing a key requires a terminal: run graphlin init --replace-key and enter it at the masked prompt.');
|
|
297
|
+
if (!interactive && ((!options.host && !resuming) ||
|
|
298
|
+
(options.allowSource === undefined && !(resuming && saved.policy)))) {
|
|
299
|
+
throw fail('setup_required', 'Setup needs a terminal, or explicit options: graphlin init --host claude|codex|both --no-source (or --allow-source with a saved key or TYPESAFE_API_KEY).');
|
|
300
|
+
}
|
|
301
|
+
const detected = await detectHosts({ run, env, cwd: paths.projectRoot, signal: options.signal });
|
|
302
|
+
write(`Detected host CLIs: ${detected.join(', ') || 'none'}.\n`);
|
|
303
|
+
if (!detected.length) throw fail('missing_host', 'Install Claude Code or Codex CLI and add it to PATH, then run graphlin init again.');
|
|
304
|
+
const selected = options.host ?? (resuming ? null : detected.length === 1 ? detected[0] :
|
|
305
|
+
await choose('Install for claude, codex, or both? ', ['claude', 'codex', 'both'], prompt));
|
|
306
|
+
const version = await (dependencies.version ?? packageVersion)();
|
|
307
|
+
const upgrading = Boolean((saved.installation?.hosts?.length || previousPending.length) && saved.installation.version !== version);
|
|
308
|
+
const hosts = [...new Set([...(selected === 'both' ? HOSTS : selected ? [selected] : []),
|
|
309
|
+
...previousPending, ...(upgrading ? saved.installation.hosts : [])])];
|
|
310
|
+
if (hosts.some(host => !detected.includes(host))) throw fail('missing_host',
|
|
311
|
+
'A selected host CLI is unavailable. Install it and add it to PATH, then run graphlin init again.');
|
|
312
|
+
write('Graphlin installs its plugin for your user account, across projects. Source consent applies only to this canonical project.\n');
|
|
313
|
+
write(`Project: ${JSON.stringify(paths.projectRoot)}\n`);
|
|
314
|
+
if (upgrading) write('Updating every recorded Graphlin host to keep the installed version consistent.\n');
|
|
315
|
+
let allowSource = options.allowSource ?? (resuming ? saved.policy?.allowSource : undefined);
|
|
316
|
+
if (allowSource === undefined) {
|
|
317
|
+
write('Source mode sends locally filtered source excerpts, user prompts, and public agent messages to TypeSafe. Metadata mode sends none of these and needs no key.\n');
|
|
318
|
+
allowSource = await choose('For this project, choose source or metadata: ', ['source', 'metadata'], prompt) === 'source';
|
|
319
|
+
}
|
|
320
|
+
const policy = { ...defaults, ...saved.policy, allowSource,
|
|
321
|
+
persistEvidence: options.persistEvidence ?? saved.policy?.persistEvidence ?? false,
|
|
322
|
+
displayEvidence: options.displayEvidence ?? saved.policy?.displayEvidence ?? true };
|
|
323
|
+
// Explicit setup plus source consent authorizes saving the supplied key so
|
|
324
|
+
// later launches do not depend on this terminal's environment.
|
|
325
|
+
let apiKey = allowSource && env.TYPESAFE_API_KEY ? env.TYPESAFE_API_KEY : undefined;
|
|
326
|
+
if (allowSource && env.TYPESAFE_API_KEY === '' && !options.replaceKey) throw fail('empty_environment_key',
|
|
327
|
+
'TYPESAFE_API_KEY is set but empty. Unset it to use a saved key or the masked prompt, or choose --no-source.');
|
|
328
|
+
if (options.replaceKey || allowSource && !(env.TYPESAFE_API_KEY ?? saved.apiKey)) {
|
|
329
|
+
if (!interactive) throw fail('key_required',
|
|
330
|
+
'Source mode needs a key. Run graphlin init in a terminal for the masked prompt, provide TYPESAFE_API_KEY through your environment, or choose --no-source.');
|
|
331
|
+
apiKey = await prompt('TypeSafe API key (masked; saved privately): ', { secret: true });
|
|
332
|
+
if (!apiKey) throw fail('key_required', 'No key was supplied. Run init again or choose --no-source.');
|
|
333
|
+
if (env.TYPESAFE_API_KEY !== undefined) write('The environment key still takes precedence. Unset TYPESAFE_API_KEY to use the newly saved key.\n');
|
|
334
|
+
}
|
|
335
|
+
if (options.signal?.aborted) throw cancelled();
|
|
336
|
+
const outputDir = await (dependencies.prepare ?? preparePackages)(paths.dataDir, version);
|
|
337
|
+
if (options.signal?.aborted) throw cancelled();
|
|
338
|
+
const installed = new Set(saved.installation?.hosts ?? []);
|
|
339
|
+
const pending = new Set(hosts);
|
|
340
|
+
const installation = () => ({ hosts: [...installed],
|
|
341
|
+
version: pending.size ? saved.installation?.version ?? version : version,
|
|
342
|
+
...(pending.size ? { pendingHosts: [...pending] } : {}) });
|
|
343
|
+
await saveSettings(paths, { policy, ...(apiKey ? { apiKey } : {}), installation: installation() });
|
|
344
|
+
apiKey = undefined;
|
|
345
|
+
for (const host of hosts) {
|
|
346
|
+
write(`Installing Graphlin for ${host}…\n`);
|
|
347
|
+
const context = { cwd: paths.projectRoot, env, signal: options.signal };
|
|
348
|
+
await configureMarketplace(host, outputDir, paths, saved, run, context, async () => {
|
|
349
|
+
installed.delete(host);
|
|
350
|
+
await saveSettings(paths, { installation: installation() });
|
|
351
|
+
write(`Rebinding Codex's Graphlin marketplace. An interrupted installation resumes on the next bare graphlin run.\n`);
|
|
352
|
+
});
|
|
353
|
+
const plugins = host === 'claude' ? await hostList(host, 'plugin', run, context) : [];
|
|
354
|
+
const alreadyInstalled = plugins.some(plugin => plugin.id === PLUGIN && plugin.scope === 'user');
|
|
355
|
+
await run(host, ['plugin', host === 'claude' ? alreadyInstalled ? 'update' : 'install' : 'add', PLUGIN,
|
|
356
|
+
...(host === 'claude' ? ['--scope', 'user'] : [])], context);
|
|
357
|
+
installed.add(host);
|
|
358
|
+
pending.delete(host);
|
|
359
|
+
await saveSettings(paths, { installation: installation() });
|
|
360
|
+
write(`Graphlin installed for ${host}.\n`);
|
|
361
|
+
}
|
|
362
|
+
write(agentInstructions({ ...paths, hosts }));
|
|
363
|
+
return { installed: hosts, version, policy };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function uninstallOnboarding(options, dependencies = {}) {
|
|
367
|
+
const { readSettings, saveSettings } = dependencies.settings ?? await settingsAPI();
|
|
368
|
+
const write = dependencies.write ?? (text => process.stderr.write(text));
|
|
369
|
+
const run = dependencies.run ?? runHost;
|
|
370
|
+
const paths = await projectPaths(options.projectRoot, options.dataDir);
|
|
371
|
+
const saved = await readSettings(paths);
|
|
372
|
+
const installed = new Set(saved.installation?.hosts ?? []);
|
|
373
|
+
const pending = new Set(saved.installation?.pendingHosts ?? []);
|
|
374
|
+
const hosts = options.host === 'both' ? HOSTS : options.host ? [options.host] : [...new Set([...installed, ...pending])];
|
|
375
|
+
const removed = [], cancelledPending = [];
|
|
376
|
+
const record = async () => saveSettings(paths, { installation: { hosts: [...installed],
|
|
377
|
+
version: saved.installation?.version ?? await (dependencies.version ?? packageVersion)(),
|
|
378
|
+
...(pending.size ? { pendingHosts: [...pending] } : {}) } });
|
|
379
|
+
write('Uninstall removes the selected Graphlin host plugin for all projects. Saved keys, history, plugin packages, and marketplace registrations are kept.\n');
|
|
380
|
+
if (!hosts.length) write('No Graphlin hosts are recorded. If installed manually, select --host claude, codex, or both.\n');
|
|
381
|
+
for (const host of hosts) {
|
|
382
|
+
const context = { cwd: paths.projectRoot, env: dependencies.env ?? process.env, signal: options.signal };
|
|
383
|
+
if (pending.has(host) && !installed.has(host)) {
|
|
384
|
+
const plugins = await hostList(host, 'plugin', run, context);
|
|
385
|
+
if (!plugins.some(plugin => host === 'claude' ? plugin.id === PLUGIN && plugin.scope === 'user' : plugin.pluginId === PLUGIN)) {
|
|
386
|
+
pending.delete(host);
|
|
387
|
+
cancelledPending.push(host);
|
|
388
|
+
await record();
|
|
389
|
+
write(`Cancelled pending installation for ${host}; its CLI reported no installed Graphlin plugin.\n`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
await run(host, ['plugin', host === 'claude' ? 'uninstall' : 'remove', PLUGIN,
|
|
394
|
+
...(host === 'claude' ? ['--scope', 'user', '--keep-data'] : [])],
|
|
395
|
+
context);
|
|
396
|
+
installed.delete(host);
|
|
397
|
+
pending.delete(host);
|
|
398
|
+
removed.push(host);
|
|
399
|
+
await record();
|
|
400
|
+
write(`Graphlin removed from ${host}.\n`);
|
|
401
|
+
}
|
|
402
|
+
await saveSettings(paths, { policy: { ...defaults, ...saved.policy, allowSource: false, persistEvidence: false } });
|
|
403
|
+
write('Source and evidence-persistence consent reset for this project. A running viewer keeps its current policy until stopped; use graphlin stop.\n');
|
|
404
|
+
return { removed, ...(cancelledPending.length ? { cancelledPending } : {}), retainedData: true };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function openViewer(url, { run = runHost, platform = process.platform, ...options } = {}) {
|
|
408
|
+
const parsed = new URL(url);
|
|
409
|
+
if (parsed.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname)) {
|
|
410
|
+
throw fail('invalid_viewer_url', 'The viewer returned an invalid local URL.');
|
|
411
|
+
}
|
|
412
|
+
await run(platform === 'darwin' ? 'open' : 'xdg-open', [url], { ...options, timeout: 3000 });
|
|
413
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
|
|
7
|
+
// Dependency-free checks for this package's deliberately small manifest profile.
|
|
8
|
+
// This is not a replacement for a host's schema validation or activation/trust.
|
|
9
|
+
export async function publicPackageFiles(root) {
|
|
10
|
+
const metadata = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
|
11
|
+
assert.ok(Array.isArray(metadata.files) && metadata.files.length > 0, 'package_files_required');
|
|
12
|
+
const files = ['package.json', ...metadata.files.map(file => {
|
|
13
|
+
// npm treats an unanchored filename such as README.md as a match in
|
|
14
|
+
// descendants too. Anchor every entry to the package root explicitly.
|
|
15
|
+
assert.ok(typeof file === 'string' && file.startsWith('./'), 'package_file_must_be_root_anchored');
|
|
16
|
+
return file.slice(2);
|
|
17
|
+
})];
|
|
18
|
+
assert.equal(new Set(files).size, files.length, 'duplicate_package_file');
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
// Exact filenames only: no wildcard or directory can silently sweep newly
|
|
21
|
+
// created credentials, local state, research, or operational material in.
|
|
22
|
+
assert.ok(typeof file === 'string' && file.length > 0 && !file.includes('\\') &&
|
|
23
|
+
!path.posix.isAbsolute(file) && !/[*?[\]{}!\u0000-\u001f]/.test(file) &&
|
|
24
|
+
file.split('/').every(part => part && part !== '.' && part !== '..'), 'invalid_package_file');
|
|
25
|
+
assert.ok(/^(?:package\.json|LICENSE|README\.md|plugin\.json|mcp\.json|\.mcp\.json|\.claude-plugin\/plugin\.json|\.codex-plugin\/plugin\.json|adapters\/(?:README\.md|(?:claude|codex|kiro)\/(?:hooks|profile)\.json)|skills\/graphlin\/SKILL\.md|runtime\/(?:[a-z0-9-]+\/)*[a-z0-9-]+\.(?:mjs|js|css|html)|schemas\/[a-z0-9-]+\.schema\.json|scripts\/(?:arguments|build-packages|collector|control|daemon|graphlin|onboarding|validate-packages)\.mjs|scripts\/collect\.sh)$/.test(file),
|
|
26
|
+
'unexpected_public_package_file');
|
|
27
|
+
let current = root;
|
|
28
|
+
for (const component of file.split('/')) {
|
|
29
|
+
current = path.join(current, component);
|
|
30
|
+
assert.equal((await lstat(current)).isSymbolicLink(), false, 'package_symlink_rejected');
|
|
31
|
+
}
|
|
32
|
+
assert.ok((await lstat(current)).isFile(), 'package_file_must_be_regular');
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function validatePackage(root) {
|
|
38
|
+
const json = async name => JSON.parse(await readFile(path.join(root, name), 'utf8'));
|
|
39
|
+
const metadata = await json('package.json');
|
|
40
|
+
const manifest = await json('plugin.json'), portable = await json('mcp.json');
|
|
41
|
+
assert.equal(manifest.$schema, 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json');
|
|
42
|
+
assert.equal(manifest.name, 'graphlin');
|
|
43
|
+
assert.match(manifest.version, /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/);
|
|
44
|
+
assert.equal(metadata.name, manifest.name);
|
|
45
|
+
assert.equal(metadata.version, manifest.version);
|
|
46
|
+
assert.equal(metadata.license, 'MIT');
|
|
47
|
+
// npm's publish normalization removes "./" from bin targets. This is
|
|
48
|
+
// separate from the files allowlist, whose root anchors must be preserved.
|
|
49
|
+
assert.deepEqual(metadata.bin, { graphlin: 'scripts/graphlin.mjs' });
|
|
50
|
+
assert.equal(metadata.repository?.url, 'git+https://github.com/royosherove/graphlin.git');
|
|
51
|
+
assert.deepEqual(metadata.publishConfig, { access: 'public', registry: 'https://registry.npmjs.org/' });
|
|
52
|
+
assert.match(await readFile(path.join(root, 'LICENSE'), 'utf8'), /MIT License/);
|
|
53
|
+
assert.match(await readFile(path.join(root, 'scripts/graphlin.mjs'), 'utf8'), /^#!\/usr\/bin\/env node\n/);
|
|
54
|
+
assert.ok(typeof manifest.description === 'string' && manifest.description.length > 0);
|
|
55
|
+
assert.equal(portable.$schema, 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json');
|
|
56
|
+
assert.deepEqual(Object.keys(portable.mcpServers), ['graphlin']);
|
|
57
|
+
assert.deepEqual(portable.mcpServers.graphlin, {
|
|
58
|
+
type: 'stdio', command: 'node', args: ['${PLUGIN_ROOT}/scripts/control.mjs'], cwd: '${PLUGIN_DATA}',
|
|
59
|
+
});
|
|
60
|
+
const compatibility = await json('.codex-plugin/plugin.json');
|
|
61
|
+
assert.equal(compatibility.name, manifest.name);
|
|
62
|
+
assert.equal(compatibility.version, manifest.version);
|
|
63
|
+
assert.equal(compatibility.skills, './skills/');
|
|
64
|
+
assert.equal(compatibility.mcpServers, './.mcp.json');
|
|
65
|
+
assert.ok(compatibility.author?.name);
|
|
66
|
+
for (const field of ['displayName', 'shortDescription', 'longDescription', 'developerName', 'category']) {
|
|
67
|
+
assert.ok(typeof compatibility.interface?.[field] === 'string' && compatibility.interface[field].length);
|
|
68
|
+
}
|
|
69
|
+
assert.ok(Array.isArray(compatibility.interface.defaultPrompt));
|
|
70
|
+
const claudePath = path.join(root, '.claude-plugin/plugin.json');
|
|
71
|
+
if (await lstat(claudePath).catch(error => { if (error.code !== 'ENOENT') throw error; })) {
|
|
72
|
+
const claude = await json('.claude-plugin/plugin.json');
|
|
73
|
+
assert.equal(claude.name, manifest.name);
|
|
74
|
+
assert.equal(claude.version, manifest.version);
|
|
75
|
+
assert.equal(claude.license, 'MIT');
|
|
76
|
+
assert.equal(claude.hooks, './adapters/claude/hooks.json');
|
|
77
|
+
}
|
|
78
|
+
const legacy = await json('.mcp.json');
|
|
79
|
+
assert.deepEqual(Object.keys(legacy.mcpServers), ['graphlin']);
|
|
80
|
+
assert.equal(legacy.mcpServers.graphlin.command, 'node');
|
|
81
|
+
assert.ok(['${CLAUDE_PLUGIN_ROOT}/scripts/control.mjs', '${PLUGIN_ROOT}/scripts/control.mjs']
|
|
82
|
+
.includes(legacy.mcpServers.graphlin.args[0]));
|
|
83
|
+
const skill = await readFile(path.join(root, 'skills/graphlin/SKILL.md'), 'utf8');
|
|
84
|
+
assert.match(skill, /^---\nname: graphlin\ndescription: [^\n]+\n---\n/);
|
|
85
|
+
for (const file of ['scripts/control.mjs', 'scripts/graphlin.mjs', 'scripts/collect.sh',
|
|
86
|
+
'runtime/daemon/server.mjs', 'runtime/collector/index.mjs', 'runtime/pipeline.mjs', 'runtime/web/index.html']) {
|
|
87
|
+
assert.ok((await lstat(path.join(root, file))).isFile());
|
|
88
|
+
}
|
|
89
|
+
for (const host of ['claude', 'codex']) {
|
|
90
|
+
const profile = await json(`adapters/${host}/profile.json`), config = await json(`adapters/${host}/hooks.json`);
|
|
91
|
+
assert.deepEqual(Object.keys(config.hooks).sort(), [...profile.events].sort());
|
|
92
|
+
assert.equal(profile.activation, 'not_verified');
|
|
93
|
+
for (const group of Object.values(config.hooks)) for (const rule of group) for (const hook of rule.hooks) {
|
|
94
|
+
assert.equal(hook.type, 'command');
|
|
95
|
+
assert.equal(hook.timeout, 2);
|
|
96
|
+
assert.match(hook.command, /if \[ -r /);
|
|
97
|
+
assert.match(hook.command, />\/dev\/null 2>&1; exit 0$/);
|
|
98
|
+
assert.ok(hook.command.includes(`/scripts/collect.sh"`));
|
|
99
|
+
assert.equal(hook.async, undefined);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (manifest.extensions) {
|
|
103
|
+
assert.equal(manifest.extensions['com.openai'].hooks, './adapters/codex/hooks.json');
|
|
104
|
+
assert.ok(manifest.extensions['com.openai'].interface.displayName);
|
|
105
|
+
}
|
|
106
|
+
const kiro = await json('adapters/kiro/profile.json');
|
|
107
|
+
assert.equal(kiro.enabled, false);
|
|
108
|
+
assert.deepEqual(kiro.events, []);
|
|
109
|
+
return { valid: true, profile: 'graphlin-local-manifests', hostActivationVerified: false };
|
|
110
|
+
}
|
|
111
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
112
|
+
try {
|
|
113
|
+
const root = path.resolve(process.argv[2] || fileURLToPath(new URL('../', import.meta.url)));
|
|
114
|
+
await publicPackageFiles(root);
|
|
115
|
+
const result = await validatePackage(root);
|
|
116
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
117
|
+
} catch { process.stderr.write('Graphlin package validation failed.\n'); process.exitCode = 1; }
|
|
118
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: graphlin
|
|
3
|
+
description: Open, start, stop, inspect, diagnose, or guide explicit installation of the local Graphlin architecture and activity viewer for a coding project, or run its offline fixture demo.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Graphlin
|
|
7
|
+
|
|
8
|
+
Use the bundled Graphlin MCP tools `start`, `stop`, `status`, and `doctor`
|
|
9
|
+
with an explicit `projectRoot`. The returned start URL contains a one-use,
|
|
10
|
+
one-minute launch token; open it for the user without copying it into project
|
|
11
|
+
files or logs. Run `start` again for a fresh URL if the token expires.
|
|
12
|
+
|
|
13
|
+
For “open Graphlin”, call `start` with the project and open the returned URL.
|
|
14
|
+
Omit policy fields to reuse the running or saved policy. A project without
|
|
15
|
+
saved consent defaults to metadata only. Set `allowSource: true` only when the user has
|
|
16
|
+
explicitly authorized sending permitted source snippets and public messages to
|
|
17
|
+
TypeSafe for this project. Set `persistEvidence: true` only for an explicit
|
|
18
|
+
request to retain approved evidence excerpts. Approved evidence display is on
|
|
19
|
+
by default and can be disabled with `displayEvidence: false`. Policies are
|
|
20
|
+
immutable for a running daemon; stop/start applies a policy change.
|
|
21
|
+
|
|
22
|
+
The service uses `TYPESAFE_API_KEY` or a privately saved user key when source
|
|
23
|
+
transmission is enabled. Never read credential settings or place keys in tool arguments, manifests, URLs,
|
|
24
|
+
graphs, source files, or chat. If the key is missing, explain the status without
|
|
25
|
+
asking the user to paste the key in chat. Direct them to `graphlin init` in their
|
|
26
|
+
own terminal for its masked prompt. Metadata collection remains available.
|
|
27
|
+
An explicitly empty environment key suppresses the saved key; unset it to use
|
|
28
|
+
the saved key.
|
|
29
|
+
For an incorrect/expired saved key, guide the user to `graphlin init --replace-key`
|
|
30
|
+
in their own terminal, then restart the viewer. The replacement prompt is masked.
|
|
31
|
+
Use `doctor` to inspect setup and classifier state; never test a key by sending
|
|
32
|
+
private project source.
|
|
33
|
+
|
|
34
|
+
For an empty diagram or missing shapes, use `status` and `doctor` first. Check
|
|
35
|
+
source consent, classifier/key status, and the viewer's hook feed. Use the
|
|
36
|
+
classification log or CLI `logs --file <path>` to investigate a specific file.
|
|
37
|
+
No hooks means check host installation and trust; hooks without shapes can
|
|
38
|
+
mean metadata mode, filtered source, stale evidence, or inconclusive classification.
|
|
39
|
+
Never treat installation success or a host version as proof hooks are active.
|
|
40
|
+
|
|
41
|
+
When MCP is unavailable, use the packaged CLI with Node.js 22 or later:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
node <plugin-root>/scripts/graphlin.mjs start --project <project>
|
|
45
|
+
node <plugin-root>/scripts/graphlin.mjs open --project <project>
|
|
46
|
+
node <plugin-root>/scripts/graphlin.mjs status --project <project>
|
|
47
|
+
node <plugin-root>/scripts/graphlin.mjs doctor --project <project>
|
|
48
|
+
node <plugin-root>/scripts/graphlin.mjs stop --project <project>
|
|
49
|
+
node <plugin-root>/scripts/graphlin.mjs export --project <project>
|
|
50
|
+
node <plugin-root>/scripts/graphlin.mjs logs --project <project>
|
|
51
|
+
node <plugin-root>/scripts/graphlin.mjs demo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Pass each path as a separate argument, quoting paths containing spaces. The
|
|
55
|
+
demo creates a dedicated local fixture project and runs source events through
|
|
56
|
+
the real pipeline using labeled offline answers. It never uses the paid API.
|
|
57
|
+
Export prints the currently displayed, sanitized snapshot as JSON.
|
|
58
|
+
|
|
59
|
+
The runtime supports private Unix sockets on macOS/Linux. Writable data defaults
|
|
60
|
+
to `~/.local/state/graphlin`, outside the installed bundle; `GRAPHLIN_DATA_DIR`
|
|
61
|
+
or CLI `--data-dir` can select another private directory. Hooks and controls
|
|
62
|
+
must use the same data directory. Snapshots are atomically written with mode
|
|
63
|
+
0600 and bounded to 2 MiB. Replay entries expire after seven days; an expired
|
|
64
|
+
stopped-daemon snapshot is deleted on the next startup, not by a background job.
|
|
65
|
+
Credentials, environment files, binary/oversized files, excluded paths, and
|
|
66
|
+
symlinks outside the project are excluded locally. Raw hook bodies are never
|
|
67
|
+
spooled. The no-training statement in TypeSafe's docs does not establish
|
|
68
|
+
default zero retention; ZDR is an enterprise offering.
|
|
69
|
+
|
|
70
|
+
Do not call drawing tools after each agent action. Passive hooks supply events
|
|
71
|
+
when the host has separately loaded and trusted them. Do not install a plugin,
|
|
72
|
+
create a marketplace, or change global host configuration as part of opening
|
|
73
|
+
or diagnosing the viewer. For an explicit setup/install request, guide the user
|
|
74
|
+
to run this in their own project terminal:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
npx --yes graphlin@latest init
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The guided CLI detects Claude/Codex, installs through native host CLIs, asks for
|
|
81
|
+
project consent, and saves a key privately if required. It builds versioned
|
|
82
|
+
packages in the Graphlin data directory, outside npm's cache. Do not run an
|
|
83
|
+
interactive key prompt through an agent tool. Non-interactive setup requires
|
|
84
|
+
`--host claude|codex|both` and explicit `--no-source` or `--allow-source`;
|
|
85
|
+
source mode needs an existing saved/environment key. Never supply a key in argv.
|
|
86
|
+
Explicit source-enabled setup saves an environment key privately for future runs.
|
|
87
|
+
After installation, start a new host session in the project: `claude` or `codex`.
|
|
88
|
+
Review project trust and Graphlin in Claude's `/plugin`, or trust hooks using
|
|
89
|
+
Codex's `/hooks`. With a custom data directory, use the printed command.
|
|
90
|
+
|
|
91
|
+
For an explicit uninstall request, use `graphlin uninstall` (the same GitHub
|
|
92
|
+
package command with `uninstall` appended). It removes only Graphlin's host
|
|
93
|
+
plugins for all projects; keys, history, packages, and marketplace registrations
|
|
94
|
+
remain. Current project source/persistence consent resets, but a running viewer
|
|
95
|
+
keeps its active policy until stopped. Do not delete data to uninstall.
|
|
96
|
+
|
|
97
|
+
`doctor` reports versions and runtime connectivity; it does not
|
|
98
|
+
claim hook activation or trust. Kiro's profile is inactive and experimental.
|
|
99
|
+
Real host activation has not been certified by the package fixtures.
|
|
100
|
+
|
|
101
|
+
Explain diagram limits: source observations are not proof of runtime
|
|
102
|
+
connectivity, generic successful commands do not verify architecture, missing
|
|
103
|
+
events mean incomplete coverage, and private reasoning is not captured.
|