@plur-ai/cli 0.9.9 → 0.9.10
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/dist/commands/hook-inject.js +178 -31
- package/dist/commands/init-remote.js +298 -0
- package/dist/index.js +3 -1
- package/package.json +1 -1
|
@@ -3,30 +3,165 @@ import {
|
|
|
3
3
|
} from "./chunk-O6WTH7H7.js";
|
|
4
4
|
|
|
5
5
|
// src/commands/hook-inject.ts
|
|
6
|
-
import { existsSync, writeFileSync, readFileSync, mkdirSync, readSync, statSync } from "fs";
|
|
7
|
-
import { join } from "path";
|
|
8
|
-
import { tmpdir } from "os";
|
|
6
|
+
import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync } from "fs";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
8
|
+
import { tmpdir, homedir } from "os";
|
|
9
9
|
import { randomUUID } from "crypto";
|
|
10
|
+
var MAX_REMOTE_TASK_CHARS = 1e3;
|
|
11
|
+
var MAX_REMOTE_RESPONSE_BYTES = 128 * 1024;
|
|
12
|
+
var REMOTE_TIMEOUT_MS = 1500;
|
|
13
|
+
var REMOTE_INJECT_LOG_DIR = join(homedir(), ".plur", "logs");
|
|
14
|
+
function remoteInjectLogPath() {
|
|
15
|
+
return join(REMOTE_INJECT_LOG_DIR, `remote-inject-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.jsonl`);
|
|
16
|
+
}
|
|
17
|
+
function logRemoteAttempt(entry) {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(REMOTE_INJECT_LOG_DIR, { recursive: true });
|
|
20
|
+
appendFileSync(remoteInjectLogPath(), JSON.stringify(entry) + "\n");
|
|
21
|
+
} catch {
|
|
22
|
+
}
|
|
23
|
+
}
|
|
10
24
|
var REMINDER_INTERVAL_MS = 10 * 60 * 1e3;
|
|
25
|
+
function findProjectConfigPath(startDir = process.cwd()) {
|
|
26
|
+
const home = resolve(homedir());
|
|
27
|
+
let dir = resolve(startDir);
|
|
28
|
+
const MAX_DEPTH = 12;
|
|
29
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
30
|
+
if (dir !== home) {
|
|
31
|
+
const candidate = join(dir, ".plur.yaml");
|
|
32
|
+
if (existsSync(candidate)) return candidate;
|
|
33
|
+
}
|
|
34
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
35
|
+
if (dir === home || dir === "/" || dir === ".") return null;
|
|
36
|
+
const parent = dirname(dir);
|
|
37
|
+
if (parent === dir) return null;
|
|
38
|
+
dir = parent;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function unquoteYamlValue(v) {
|
|
43
|
+
return v.replace(/^(['"])(.*)\1$/, "$2");
|
|
44
|
+
}
|
|
11
45
|
function readProjectConfig() {
|
|
12
|
-
const configPath =
|
|
13
|
-
if (!
|
|
46
|
+
const configPath = findProjectConfigPath();
|
|
47
|
+
if (!configPath) return {};
|
|
14
48
|
try {
|
|
15
|
-
const content = readFileSync(configPath, "utf8");
|
|
49
|
+
const content = readFileSync(configPath, "utf8").replace(/^/, "");
|
|
16
50
|
const config = {};
|
|
17
|
-
|
|
51
|
+
let inListKey = null;
|
|
52
|
+
let listAcc = [];
|
|
53
|
+
const finishList = () => {
|
|
54
|
+
if (inListKey === "remote_scopes") {
|
|
55
|
+
config.remote_scopes = listAcc;
|
|
56
|
+
}
|
|
57
|
+
inListKey = null;
|
|
58
|
+
listAcc = [];
|
|
59
|
+
};
|
|
60
|
+
for (const rawLine of content.split("\n")) {
|
|
61
|
+
const line = rawLine.replace(/\r$/, "");
|
|
18
62
|
const trimmed = line.trim();
|
|
19
63
|
if (trimmed.startsWith("#") || !trimmed) continue;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
64
|
+
if (inListKey === "remote_scopes" && trimmed.startsWith("-")) {
|
|
65
|
+
listAcc.push(unquoteYamlValue(trimmed.slice(1).trim()));
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (inListKey) finishList();
|
|
69
|
+
const colonIdx = trimmed.indexOf(":");
|
|
70
|
+
if (colonIdx < 0) continue;
|
|
71
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
72
|
+
const value = unquoteYamlValue(trimmed.slice(colonIdx + 1).trim());
|
|
73
|
+
switch (key) {
|
|
74
|
+
case "domain":
|
|
75
|
+
config.domain = value;
|
|
76
|
+
break;
|
|
77
|
+
case "scope":
|
|
78
|
+
config.scope = value;
|
|
79
|
+
break;
|
|
80
|
+
case "remote_url":
|
|
81
|
+
config.remote_url = value;
|
|
82
|
+
break;
|
|
83
|
+
case "remote_token":
|
|
84
|
+
config.remote_token = value;
|
|
85
|
+
break;
|
|
86
|
+
case "remote_scopes":
|
|
87
|
+
if (value === "" || value === "|" || value === ">") {
|
|
88
|
+
inListKey = "remote_scopes";
|
|
89
|
+
listAcc = [];
|
|
90
|
+
} else {
|
|
91
|
+
config.remote_scopes = value.split(",").map((s) => unquoteYamlValue(s.trim())).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
24
95
|
}
|
|
96
|
+
finishList();
|
|
25
97
|
return config;
|
|
26
98
|
} catch {
|
|
27
99
|
return {};
|
|
28
100
|
}
|
|
29
101
|
}
|
|
102
|
+
async function tryRemoteInject(config, task) {
|
|
103
|
+
if (!config.remote_url || !config.remote_token) return null;
|
|
104
|
+
const startTs = Date.now();
|
|
105
|
+
let base;
|
|
106
|
+
try {
|
|
107
|
+
base = new URL(config.remote_url).origin;
|
|
108
|
+
} catch {
|
|
109
|
+
logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url: config.remote_url ?? "?", outcome: "bad_response", ms: 0, detail: "invalid URL" });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const url = `${base}/api/v1/inject`;
|
|
113
|
+
const truncatedTask = task.length > MAX_REMOTE_TASK_CHARS ? task.slice(0, MAX_REMOTE_TASK_CHARS) : task;
|
|
114
|
+
const body = { task: truncatedTask };
|
|
115
|
+
if (config.remote_scopes && config.remote_scopes.length > 0) {
|
|
116
|
+
body.scopes = config.remote_scopes;
|
|
117
|
+
}
|
|
118
|
+
const ctrl = new AbortController();
|
|
119
|
+
const t = setTimeout(() => ctrl.abort(), REMOTE_TIMEOUT_MS);
|
|
120
|
+
try {
|
|
121
|
+
const r = await fetch(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
signal: ctrl.signal,
|
|
124
|
+
headers: {
|
|
125
|
+
"authorization": `Bearer ${config.remote_token}`,
|
|
126
|
+
"content-type": "application/json"
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify(body)
|
|
129
|
+
});
|
|
130
|
+
if (!r.ok) {
|
|
131
|
+
logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "http_error", ms: Date.now() - startTs, http: r.status });
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const contentLength = r.headers.get("content-length");
|
|
135
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_REMOTE_RESPONSE_BYTES) {
|
|
136
|
+
logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "oversize", ms: Date.now() - startTs, http: r.status, detail: `content-length=${contentLength}` });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const data = await r.json();
|
|
140
|
+
if (typeof data.text !== "string" || !data.text.trim()) {
|
|
141
|
+
logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "bad_response", ms: Date.now() - startTs, http: r.status, detail: "empty or non-string text field" });
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const count = typeof data.count === "number" ? data.count : 0;
|
|
145
|
+
logRemoteAttempt({ ts: (/* @__PURE__ */ new Date()).toISOString(), url, outcome: "ok", ms: Date.now() - startTs, http: r.status, engrams: count });
|
|
146
|
+
return {
|
|
147
|
+
text: data.text,
|
|
148
|
+
count,
|
|
149
|
+
injectedIds: Array.isArray(data.injected_ids) ? data.injected_ids : []
|
|
150
|
+
};
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const isAbort = err instanceof Error && err.name === "AbortError";
|
|
153
|
+
logRemoteAttempt({
|
|
154
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
155
|
+
url,
|
|
156
|
+
outcome: isAbort ? "timeout" : "network_error",
|
|
157
|
+
ms: Date.now() - startTs,
|
|
158
|
+
detail: err instanceof Error ? err.message.slice(0, 120) : void 0
|
|
159
|
+
});
|
|
160
|
+
return null;
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(t);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
30
165
|
function sessionDir() {
|
|
31
166
|
const dir = join(tmpdir(), "plur-sessions");
|
|
32
167
|
mkdirSync(dir, { recursive: true });
|
|
@@ -177,25 +312,36 @@ ${parts2.join("\n")}` };
|
|
|
177
312
|
const injectOpts = projectConfig.scope ? { scope: projectConfig.scope } : void 0;
|
|
178
313
|
let context = null;
|
|
179
314
|
let count = 0;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
context = parts2.join("\n");
|
|
188
|
-
count = result.count;
|
|
315
|
+
let remoteUsed = false;
|
|
316
|
+
if (projectConfig.remote_url && projectConfig.remote_token) {
|
|
317
|
+
const remote = await tryRemoteInject(projectConfig, task);
|
|
318
|
+
if (remote && remote.count > 0) {
|
|
319
|
+
context = remote.text;
|
|
320
|
+
count = remote.count;
|
|
321
|
+
remoteUsed = true;
|
|
189
322
|
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
if (result.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
323
|
+
}
|
|
324
|
+
if (!remoteUsed) {
|
|
325
|
+
try {
|
|
326
|
+
const result = await plur.injectHybrid(task, injectOpts);
|
|
327
|
+
if (result.count > 0) {
|
|
328
|
+
const parts2 = [];
|
|
329
|
+
if (result.directives) parts2.push(result.directives);
|
|
330
|
+
if (result.constraints) parts2.push(result.constraints);
|
|
331
|
+
if (result.consider) parts2.push(result.consider);
|
|
332
|
+
context = parts2.join("\n");
|
|
333
|
+
count = result.count;
|
|
334
|
+
}
|
|
335
|
+
} catch {
|
|
336
|
+
const result = plur.inject(task, injectOpts);
|
|
337
|
+
if (result.count > 0) {
|
|
338
|
+
const parts2 = [];
|
|
339
|
+
if (result.directives) parts2.push(result.directives);
|
|
340
|
+
if (result.constraints) parts2.push(result.constraints);
|
|
341
|
+
if (result.consider) parts2.push(result.consider);
|
|
342
|
+
context = parts2.join("\n");
|
|
343
|
+
count = result.count;
|
|
344
|
+
}
|
|
199
345
|
}
|
|
200
346
|
}
|
|
201
347
|
const parts = [];
|
|
@@ -205,10 +351,11 @@ ${parts2.join("\n")}` };
|
|
|
205
351
|
sessionId = markerData.sessionId;
|
|
206
352
|
} catch {
|
|
207
353
|
}
|
|
354
|
+
const sourceLabel = remoteUsed ? " (Enterprise)" : "";
|
|
208
355
|
if (isRehydrate) {
|
|
209
|
-
parts.push(`[PLUR Memory \u2014 rehydrated after compaction, ${count} engrams]`);
|
|
356
|
+
parts.push(`[PLUR Memory${sourceLabel} \u2014 rehydrated after compaction, ${count} engrams]`);
|
|
210
357
|
} else {
|
|
211
|
-
parts.push(`[PLUR Memory \u2014 session started, ${count} engrams injected]`);
|
|
358
|
+
parts.push(`[PLUR Memory${sourceLabel} \u2014 session started, ${count} engrams injected]`);
|
|
212
359
|
if (sessionId) parts.push(`Session ID: ${sessionId}`);
|
|
213
360
|
if (projectConfig.domain) parts.push(`Project domain: ${projectConfig.domain}`);
|
|
214
361
|
if (projectConfig.scope) parts.push(`Project scope: ${projectConfig.scope} \u2014 use this scope for plur_learn calls`);
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
outputText
|
|
3
|
+
} from "./chunk-7U4W4J3G.js";
|
|
4
|
+
|
|
5
|
+
// src/commands/init-remote.ts
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync } from "fs";
|
|
7
|
+
import { dirname, join, resolve } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
var HELP = `plur init-remote \u2014 opt this project into recall from PLUR Enterprise
|
|
10
|
+
|
|
11
|
+
USAGE
|
|
12
|
+
plur init-remote --url <enterprise-url> --token <api-key> [--scopes <list>]
|
|
13
|
+
plur init-remote --verify Check connectivity against existing .plur.yaml
|
|
14
|
+
|
|
15
|
+
OPTIONS
|
|
16
|
+
--url URL Enterprise base URL, e.g. https://plur.datafund.io
|
|
17
|
+
--token KEY API key for authentication
|
|
18
|
+
--scopes SCOPES Optional comma-separated scope whitelist
|
|
19
|
+
e.g. "org:plur,group:plur/engineering"
|
|
20
|
+
--no-gitignore Skip adding .plur.yaml to .gitignore (NOT RECOMMENDED \u2014
|
|
21
|
+
the token is sensitive)
|
|
22
|
+
--verify Read existing .plur.yaml and test the /api/v1/me
|
|
23
|
+
endpoint against the configured remote
|
|
24
|
+
|
|
25
|
+
WHAT THIS DOES
|
|
26
|
+
Writes .plur.yaml in the current directory with remote_url, remote_token,
|
|
27
|
+
and optional remote_scopes fields. The UserPromptSubmit hook will then
|
|
28
|
+
call \${remote_url}/api/v1/inject for each prompt (before falling back to
|
|
29
|
+
local PLUR). The hook walks upward from the current working directory to
|
|
30
|
+
find .plur.yaml, so you can work from any subdirectory.
|
|
31
|
+
|
|
32
|
+
WITHOUT this command, projects stay 100% local-only and Enterprise
|
|
33
|
+
never sees their prompts.
|
|
34
|
+
`;
|
|
35
|
+
function parseArgs(args) {
|
|
36
|
+
const out = {};
|
|
37
|
+
const consumeValue = (i, flag) => {
|
|
38
|
+
const next = args[i + 1];
|
|
39
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
40
|
+
return { error: `${flag} requires a value (got ${next === void 0 ? "nothing" : `another flag: ${next}`})` };
|
|
41
|
+
}
|
|
42
|
+
return { value: next };
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < args.length; i++) {
|
|
45
|
+
const a = args[i];
|
|
46
|
+
if (a === "--help" || a === "-h") {
|
|
47
|
+
out.help = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (a === "--verify") {
|
|
51
|
+
out.verify = true;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (a === "--no-gitignore") {
|
|
55
|
+
out.noGitignore = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a === "--url" || a === "--token" || a === "--scopes") {
|
|
59
|
+
const r = consumeValue(i, a);
|
|
60
|
+
if ("error" in r) return r;
|
|
61
|
+
i++;
|
|
62
|
+
if (a === "--url") out.url = r.value;
|
|
63
|
+
if (a === "--token") out.token = r.value;
|
|
64
|
+
if (a === "--scopes") out.scopes = r.value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function stripRemoteKeys(content) {
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
const out = [];
|
|
73
|
+
let skippingList = false;
|
|
74
|
+
const REMOTE_KEY = /^remote_(url|token|scopes)\s*:(.*)$/;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
if (skippingList) {
|
|
78
|
+
if (trimmed === "" || trimmed.startsWith("-")) continue;
|
|
79
|
+
skippingList = false;
|
|
80
|
+
}
|
|
81
|
+
const m = trimmed.match(REMOTE_KEY);
|
|
82
|
+
if (m) {
|
|
83
|
+
const key = m[1];
|
|
84
|
+
const rest = m[2].trim();
|
|
85
|
+
if (key === "scopes" && (rest === "" || rest === "|" || rest === ">")) {
|
|
86
|
+
skippingList = true;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out.push(line);
|
|
91
|
+
}
|
|
92
|
+
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
93
|
+
return out.join("\n");
|
|
94
|
+
}
|
|
95
|
+
function buildConfigBody(existing, url, token, scopes) {
|
|
96
|
+
const stripped = stripRemoteKeys(existing);
|
|
97
|
+
const sep = stripped.length > 0 && !stripped.endsWith("\n") ? "\n\n" : stripped.length > 0 ? "\n" : "";
|
|
98
|
+
const block = [];
|
|
99
|
+
block.push("# --- PLUR Enterprise remote (opt-in for this project) ---");
|
|
100
|
+
block.push("# remote_token is sensitive \u2014 keep .plur.yaml in .gitignore.");
|
|
101
|
+
block.push(`remote_url: ${url}`);
|
|
102
|
+
block.push(`remote_token: ${token}`);
|
|
103
|
+
if (scopes && scopes.length > 0) {
|
|
104
|
+
block.push("remote_scopes:");
|
|
105
|
+
for (const s of scopes) block.push(` - ${s}`);
|
|
106
|
+
}
|
|
107
|
+
return stripped + sep + block.join("\n") + "\n";
|
|
108
|
+
}
|
|
109
|
+
function ensureGitignore() {
|
|
110
|
+
const home = resolve(homedir());
|
|
111
|
+
let dir = resolve(process.cwd());
|
|
112
|
+
let gitignorePath = null;
|
|
113
|
+
const MAX_DEPTH = 12;
|
|
114
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
115
|
+
const candidate = join(dir, ".gitignore");
|
|
116
|
+
if (existsSync(candidate)) {
|
|
117
|
+
gitignorePath = candidate;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (existsSync(join(dir, ".git"))) break;
|
|
121
|
+
if (dir === home || dir === "/" || dir === ".") break;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break;
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
const PATTERN = ".plur.yaml";
|
|
127
|
+
if (!gitignorePath) {
|
|
128
|
+
const newPath = join(process.cwd(), ".gitignore");
|
|
129
|
+
writeFileSync(newPath, `# Added by 'plur init-remote' \u2014 .plur.yaml may hold an API token
|
|
130
|
+
${PATTERN}
|
|
131
|
+
`);
|
|
132
|
+
return { path: newPath, action: "created" };
|
|
133
|
+
}
|
|
134
|
+
const content = readFileSync(gitignorePath, "utf8");
|
|
135
|
+
const already = content.split("\n").some((l) => l.trim() === PATTERN);
|
|
136
|
+
if (already) return { path: gitignorePath, action: "already" };
|
|
137
|
+
const sep = content.endsWith("\n") ? "" : "\n";
|
|
138
|
+
appendFileSync(gitignorePath, `${sep}# Added by 'plur init-remote' \u2014 .plur.yaml may hold an API token
|
|
139
|
+
${PATTERN}
|
|
140
|
+
`);
|
|
141
|
+
return { path: gitignorePath, action: "added" };
|
|
142
|
+
}
|
|
143
|
+
function findExistingConfigPath() {
|
|
144
|
+
const home = resolve(homedir());
|
|
145
|
+
let dir = resolve(process.cwd());
|
|
146
|
+
const MAX_DEPTH = 12;
|
|
147
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
148
|
+
if (dir !== home) {
|
|
149
|
+
const candidate = join(dir, ".plur.yaml");
|
|
150
|
+
if (existsSync(candidate)) return candidate;
|
|
151
|
+
}
|
|
152
|
+
if (existsSync(join(dir, ".git"))) return null;
|
|
153
|
+
if (dir === home || dir === "/" || dir === ".") return null;
|
|
154
|
+
const parent = dirname(dir);
|
|
155
|
+
if (parent === dir) return null;
|
|
156
|
+
dir = parent;
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
function readRemoteFromConfig(path) {
|
|
161
|
+
if (!existsSync(path)) return {};
|
|
162
|
+
const content = readFileSync(path, "utf8");
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const line of content.split("\n")) {
|
|
165
|
+
const trimmed = line.trim();
|
|
166
|
+
if (trimmed.startsWith("#") || !trimmed) continue;
|
|
167
|
+
const m = trimmed.match(/^(remote_url|remote_token)\s*:\s*(.+)$/);
|
|
168
|
+
if (m) {
|
|
169
|
+
if (m[1] === "remote_url") out.url = m[2].trim();
|
|
170
|
+
if (m[1] === "remote_token") out.token = m[2].trim();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
async function verifyConnectivity(url, token) {
|
|
176
|
+
let base;
|
|
177
|
+
try {
|
|
178
|
+
base = new URL(url).origin;
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
181
|
+
}
|
|
182
|
+
const probeUrl = `${base}/api/v1/me`;
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
185
|
+
try {
|
|
186
|
+
const r = await fetch(probeUrl, {
|
|
187
|
+
signal: ctrl.signal,
|
|
188
|
+
headers: { "authorization": `Bearer ${token}`, "accept": "application/json" }
|
|
189
|
+
});
|
|
190
|
+
if (r.status === 401) throw new Error(`401 Unauthorized \u2014 check your API token`);
|
|
191
|
+
if (r.status === 403) throw new Error(`403 Forbidden \u2014 token lacks /me access`);
|
|
192
|
+
if (!r.ok) throw new Error(`HTTP ${r.status} from ${probeUrl}`);
|
|
193
|
+
const data = await r.json();
|
|
194
|
+
if (!data.username) throw new Error(`Unexpected response shape from ${probeUrl}`);
|
|
195
|
+
return {
|
|
196
|
+
username: data.username,
|
|
197
|
+
org_id: data.org_id ?? "(unknown)",
|
|
198
|
+
scopes: Array.isArray(data.scopes) ? data.scopes : []
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function run(args, flags) {
|
|
205
|
+
const parsed = parseArgs(args);
|
|
206
|
+
if ("error" in parsed) {
|
|
207
|
+
outputText(`Error: ${parsed.error}
|
|
208
|
+
|
|
209
|
+
${HELP}`, flags);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
const opts = parsed;
|
|
213
|
+
if (opts.help) {
|
|
214
|
+
outputText(HELP, flags);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const configPath = join(process.cwd(), ".plur.yaml");
|
|
218
|
+
if (opts.verify) {
|
|
219
|
+
const verifyPath = findExistingConfigPath() ?? configPath;
|
|
220
|
+
const cfg = readRemoteFromConfig(verifyPath);
|
|
221
|
+
if (!cfg.url || !cfg.token) {
|
|
222
|
+
outputText(`No remote config found (walked upward from ${process.cwd()}). Run \`plur init-remote --url <url> --token <key>\` first.`, flags);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
outputText(`Using config at ${verifyPath}`, flags);
|
|
226
|
+
try {
|
|
227
|
+
const me = await verifyConnectivity(cfg.url, cfg.token);
|
|
228
|
+
outputText(`\u2713 Connected to ${cfg.url} as ${me.username} (org: ${me.org_id})`, flags);
|
|
229
|
+
outputText(` readable scopes: ${me.scopes.length === 0 ? "(none)" : me.scopes.join(", ")}`, flags);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
outputText(`\u2717 Connection failed: ${err.message}`, flags);
|
|
232
|
+
process.exit(2);
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!opts.url || !opts.token) {
|
|
237
|
+
outputText(`Missing required flags.
|
|
238
|
+
${HELP}`, flags);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
if (/[\n\r\t]/.test(opts.token)) {
|
|
242
|
+
outputText(`Error: token contains newline/tab characters. Refusing to write a corrupt config.`, flags);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const u = new URL(opts.url);
|
|
247
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
248
|
+
outputText(`Error: remote_url must be http:// or https:// (got ${u.protocol})`, flags);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
outputText(`Error: remote_url is not a valid URL: ${opts.url}`, flags);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
outputText(`Testing connectivity to ${opts.url}...`, flags);
|
|
256
|
+
try {
|
|
257
|
+
const me = await verifyConnectivity(opts.url, opts.token);
|
|
258
|
+
outputText(`\u2713 Authenticated as ${me.username} (org: ${me.org_id})`, flags);
|
|
259
|
+
outputText(` readable scopes: ${me.scopes.length === 0 ? "(none)" : me.scopes.join(", ")}`, flags);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
outputText(`\u2717 Connection failed: ${err.message}`, flags);
|
|
262
|
+
outputText(` Refusing to write a broken config. Fix the URL/token and re-run.`, flags);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
266
|
+
const next = buildConfigBody(existing, opts.url, opts.token, opts.scopes);
|
|
267
|
+
writeFileSync(configPath, next);
|
|
268
|
+
outputText(`\u2713 Wrote ${configPath}`, flags);
|
|
269
|
+
if (opts.scopes && opts.scopes.length > 0) {
|
|
270
|
+
outputText(` scope whitelist: ${opts.scopes.join(", ")}`, flags);
|
|
271
|
+
} else {
|
|
272
|
+
outputText(` scope whitelist: (none \u2014 hook will query all readable scopes)`, flags);
|
|
273
|
+
}
|
|
274
|
+
if (!opts.noGitignore) {
|
|
275
|
+
const gi = ensureGitignore();
|
|
276
|
+
if (gi.action === "added") outputText(`\u2713 Added .plur.yaml to ${gi.path}`, flags);
|
|
277
|
+
else if (gi.action === "created") outputText(`\u2713 Created ${gi.path} with .plur.yaml entry`, flags);
|
|
278
|
+
else outputText(`\u2713 ${gi.path} already excludes .plur.yaml`, flags);
|
|
279
|
+
} else {
|
|
280
|
+
outputText(`\u26A0 Skipped .gitignore (--no-gitignore). The token in .plur.yaml is sensitive.`, flags);
|
|
281
|
+
}
|
|
282
|
+
outputText(`
|
|
283
|
+
Done. The UserPromptSubmit hook will now query ${opts.url} on every prompt`, flags);
|
|
284
|
+
outputText(`from this directory tree (bounded by the nearest .git). Personal/non-project`, flags);
|
|
285
|
+
outputText(`sessions (without a .plur.yaml in the path) stay local-only.`, flags);
|
|
286
|
+
outputText(``, flags);
|
|
287
|
+
outputText(`\u26A0 Token sensitivity:`, flags);
|
|
288
|
+
outputText(` .plur.yaml now contains an API token in plaintext.`, flags);
|
|
289
|
+
outputText(` - .gitignore protects against git commits but NOT against cloud sync`, flags);
|
|
290
|
+
outputText(` (iCloud Drive, Dropbox, Google Drive). If this project lives in a`, flags);
|
|
291
|
+
outputText(` synced folder, the token will leave your machine.`, flags);
|
|
292
|
+
outputText(` - Also not protected: \`cp -r\`, \`zip\`, \`rsync\`, archived backups.`, flags);
|
|
293
|
+
outputText(` - Consider moving the token to an env var if your project ships with`, flags);
|
|
294
|
+
outputText(` others (future: env-var substitution in .plur.yaml).`, flags);
|
|
295
|
+
}
|
|
296
|
+
export {
|
|
297
|
+
run
|
|
298
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -49,7 +49,7 @@ function createPlur(flags2) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// src/index.ts
|
|
52
|
-
var VERSION = "0.9.
|
|
52
|
+
var VERSION = "0.9.10";
|
|
53
53
|
var argv = process.argv.slice(2);
|
|
54
54
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
55
55
|
console.log(VERSION);
|
|
@@ -82,6 +82,7 @@ Commands:
|
|
|
82
82
|
stores list List configured stores
|
|
83
83
|
stores add <path> Add a knowledge store
|
|
84
84
|
init Install Claude Code hooks + register plur MCP server
|
|
85
|
+
init-remote Opt this project into recall from a PLUR Enterprise server
|
|
85
86
|
doctor Diagnose Claude Code / Claude Desktop integration
|
|
86
87
|
audit [--source X] Audit working memory (claude-code|claw|hermes) for conflicts vs engrams
|
|
87
88
|
hook-inject (internal) Hook handler for engram injection
|
|
@@ -124,6 +125,7 @@ var COMMANDS = {
|
|
|
124
125
|
stores: "./commands/stores.js",
|
|
125
126
|
migrate: "./commands/migrate.js",
|
|
126
127
|
init: "./commands/init.js",
|
|
128
|
+
"init-remote": "./commands/init-remote.js",
|
|
127
129
|
doctor: "./commands/doctor.js",
|
|
128
130
|
audit: "./commands/audit.js",
|
|
129
131
|
"hook-inject": "./commands/hook-inject.js",
|