contextspin 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/.contextspin.example.json +72 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/package.json +40 -0
- package/src/cli.js +492 -0
- package/src/config.js +232 -0
- package/src/daemon-entry.js +8 -0
- package/src/daemon.js +294 -0
- package/src/formatter.js +166 -0
- package/src/inject/patcher.js +757 -0
- package/src/inject/statusline.js +310 -0
- package/src/runner.js +69 -0
- package/src/sources/cli.js +148 -0
- package/src/sources/http.js +294 -0
- package/src/sources/mcp.js +586 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// src/sources/http.js — HTTP source: fetch a URL and turn the response into records.
|
|
2
|
+
|
|
3
|
+
import { interpolate } from '../formatter.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal jq-style path evaluator (a tiny subset of jq).
|
|
7
|
+
*
|
|
8
|
+
* Supported subset:
|
|
9
|
+
* - identity: "." -> returns the input unchanged
|
|
10
|
+
* - leading dot: ".a", ".a.b"
|
|
11
|
+
* - dot keys: "a.b" (a leading dot is optional)
|
|
12
|
+
* - bracket index: ".a[0]", ".a.b[2]"
|
|
13
|
+
* - iteration: ".[]" or ".a[]" yields an array of the elements; if a
|
|
14
|
+
* later key follows an iterated array, the key is mapped
|
|
15
|
+
* over each element (".a[].b")
|
|
16
|
+
* - pipe: "|" chains expressions left-to-right (".a | .b[]")
|
|
17
|
+
*
|
|
18
|
+
* Anything outside this subset (unknown syntax) returns the input UNCHANGED
|
|
19
|
+
* rather than throwing, so a misconfigured jq expression degrades gracefully.
|
|
20
|
+
*
|
|
21
|
+
* @param {*} data - The parsed JSON input.
|
|
22
|
+
* @param {string} expr - The jq-style expression.
|
|
23
|
+
* @returns {*} The selected value (often an array when iteration is used).
|
|
24
|
+
*/
|
|
25
|
+
export function jqPath(data, expr) {
|
|
26
|
+
if (typeof expr !== 'string') return data;
|
|
27
|
+
const trimmed = expr.trim();
|
|
28
|
+
if (trimmed === '' || trimmed === '.') return data;
|
|
29
|
+
|
|
30
|
+
// Pipe: evaluate each stage left-to-right.
|
|
31
|
+
if (trimmed.includes('|')) {
|
|
32
|
+
return trimmed
|
|
33
|
+
.split('|')
|
|
34
|
+
.reduce((acc, stage) => jqPath(acc, stage.trim()), data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tokens = tokenizeJq(trimmed);
|
|
38
|
+
if (tokens === null) {
|
|
39
|
+
// Unsupported expression: return input unchanged.
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let current = data;
|
|
44
|
+
let iterating = false; // whether `current` is a list produced by `[]`
|
|
45
|
+
|
|
46
|
+
for (const token of tokens) {
|
|
47
|
+
if (token.type === 'key') {
|
|
48
|
+
if (iterating) {
|
|
49
|
+
// Map the key over each element of the iterated array.
|
|
50
|
+
if (!Array.isArray(current)) return undefined;
|
|
51
|
+
current = current.map((item) => readKey(item, token.name));
|
|
52
|
+
// Result of mapping a key stays a plain array value, not iteration.
|
|
53
|
+
iterating = false;
|
|
54
|
+
} else {
|
|
55
|
+
current = readKey(current, token.name);
|
|
56
|
+
}
|
|
57
|
+
} else if (token.type === 'index') {
|
|
58
|
+
if (iterating) {
|
|
59
|
+
if (!Array.isArray(current)) return undefined;
|
|
60
|
+
current = current.map((item) =>
|
|
61
|
+
Array.isArray(item) ? item[token.index] : undefined
|
|
62
|
+
);
|
|
63
|
+
iterating = false;
|
|
64
|
+
} else {
|
|
65
|
+
current = Array.isArray(current) ? current[token.index] : undefined;
|
|
66
|
+
}
|
|
67
|
+
} else if (token.type === 'iterate') {
|
|
68
|
+
// Produce an array of the current value's elements.
|
|
69
|
+
if (iterating) {
|
|
70
|
+
// Flatten one level of an already-iterated array.
|
|
71
|
+
if (!Array.isArray(current)) return undefined;
|
|
72
|
+
const flat = [];
|
|
73
|
+
for (const item of current) {
|
|
74
|
+
if (Array.isArray(item)) flat.push(...item);
|
|
75
|
+
}
|
|
76
|
+
current = flat;
|
|
77
|
+
} else {
|
|
78
|
+
if (!Array.isArray(current)) return undefined;
|
|
79
|
+
current = current.slice();
|
|
80
|
+
}
|
|
81
|
+
iterating = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return current;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a key from an object, tolerating null/undefined.
|
|
90
|
+
*
|
|
91
|
+
* @param {*} obj
|
|
92
|
+
* @param {string} key
|
|
93
|
+
* @returns {*}
|
|
94
|
+
*/
|
|
95
|
+
function readKey(obj, key) {
|
|
96
|
+
if (obj === null || obj === undefined) return undefined;
|
|
97
|
+
return obj[key];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Tokenize a single (non-piped) jq expression into key/index/iterate tokens.
|
|
102
|
+
* Returns null if the expression uses unsupported syntax.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} expr
|
|
105
|
+
* @returns {Array<{type:string,name?:string,index?:number}>|null}
|
|
106
|
+
*/
|
|
107
|
+
function tokenizeJq(expr) {
|
|
108
|
+
// Strip an optional leading dot, then walk the string.
|
|
109
|
+
let s = expr.startsWith('.') ? expr.slice(1) : expr;
|
|
110
|
+
const tokens = [];
|
|
111
|
+
|
|
112
|
+
// Each segment is either a key, a [index], or [] (iterate), separated by
|
|
113
|
+
// dots. We hand-parse to support things like `a[0].b[]`.
|
|
114
|
+
let i = 0;
|
|
115
|
+
const n = s.length;
|
|
116
|
+
|
|
117
|
+
// Helper to read an identifier (key) starting at i.
|
|
118
|
+
const readIdent = () => {
|
|
119
|
+
let start = i;
|
|
120
|
+
while (i < n) {
|
|
121
|
+
const ch = s[i];
|
|
122
|
+
if (ch === '.' || ch === '[') break;
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
return s.slice(start, i);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Leading-dot-only forms like ".[]" become s = "[]".
|
|
129
|
+
while (i < n) {
|
|
130
|
+
const ch = s[i];
|
|
131
|
+
if (ch === '.') {
|
|
132
|
+
i += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ch === '[') {
|
|
136
|
+
const close = s.indexOf(']', i);
|
|
137
|
+
if (close === -1) return null;
|
|
138
|
+
const inner = s.slice(i + 1, close).trim();
|
|
139
|
+
if (inner === '') {
|
|
140
|
+
tokens.push({ type: 'iterate' });
|
|
141
|
+
} else if (/^\d+$/.test(inner)) {
|
|
142
|
+
tokens.push({ type: 'index', index: Number(inner) });
|
|
143
|
+
} else {
|
|
144
|
+
// Quoted keys or anything else: unsupported.
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
i = close + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// Otherwise, an identifier key.
|
|
151
|
+
const ident = readIdent();
|
|
152
|
+
if (ident === '') return null;
|
|
153
|
+
// Keys must be simple identifiers in this subset.
|
|
154
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(ident)) return null;
|
|
155
|
+
tokens.push({ type: 'key', name: ident });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return tokens;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fetch an HTTP(S) endpoint and normalize the response into records.
|
|
163
|
+
*
|
|
164
|
+
* The URL and header values are interpolated (so `{{ env.TOKEN }}` works).
|
|
165
|
+
* The body, if an object, is JSON-stringified and content-type is set. The
|
|
166
|
+
* request is aborted after timeoutMs. A non-OK status throws. The response is
|
|
167
|
+
* parsed as JSON when possible; if JSON parsing fails the raw text is returned
|
|
168
|
+
* as a single `{ text }` record. When `source.jq` is present it is applied to
|
|
169
|
+
* the parsed JSON before normalization.
|
|
170
|
+
*
|
|
171
|
+
* Normalization to an array:
|
|
172
|
+
* - array -> elements (objects kept; primitive -> { value, text:String })
|
|
173
|
+
* - object -> [object]
|
|
174
|
+
* - primitive -> [{ value, text: String(value) }]
|
|
175
|
+
*
|
|
176
|
+
* @param {{ url: string, method?: string, headers?: Object, body?: *, jq?: string }} source
|
|
177
|
+
* @param {{ timeoutMs?: number, env?: Object }} [opts]
|
|
178
|
+
* @returns {Promise<Array<object>>}
|
|
179
|
+
*/
|
|
180
|
+
export async function fetchHttp(source, opts = {}) {
|
|
181
|
+
const timeoutMs = opts.timeoutMs ?? 15000;
|
|
182
|
+
const env = opts.env ?? process.env;
|
|
183
|
+
|
|
184
|
+
const url = interpolate(source.url, {}, env);
|
|
185
|
+
|
|
186
|
+
// Interpolate header values (keys are taken literally).
|
|
187
|
+
const headers = {};
|
|
188
|
+
if (source.headers && typeof source.headers === 'object') {
|
|
189
|
+
for (const [key, value] of Object.entries(source.headers)) {
|
|
190
|
+
headers[key] = interpolate(String(value), {}, env);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const method = source.method || 'GET';
|
|
195
|
+
|
|
196
|
+
let body;
|
|
197
|
+
if (source.body !== undefined && source.body !== null) {
|
|
198
|
+
if (typeof source.body === 'object') {
|
|
199
|
+
body = JSON.stringify(source.body);
|
|
200
|
+
if (!hasHeader(headers, 'content-type')) {
|
|
201
|
+
headers['Content-Type'] = 'application/json';
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
body = source.body;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const controller = new AbortController();
|
|
209
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
210
|
+
|
|
211
|
+
// Keep the abort timer armed across BOTH the fetch and the body read: fetch()
|
|
212
|
+
// resolves as soon as the response HEADERS arrive, so a stalled or slow body
|
|
213
|
+
// would otherwise hang past timeoutMs. We clear the timer only once the body
|
|
214
|
+
// has been fully read (or the request has failed).
|
|
215
|
+
let bodyText = '';
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(url, {
|
|
218
|
+
method,
|
|
219
|
+
headers,
|
|
220
|
+
body,
|
|
221
|
+
signal: controller.signal,
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) {
|
|
224
|
+
throw new Error(`http source failed: ${res.status} ${url}`);
|
|
225
|
+
}
|
|
226
|
+
// Read the body once as text, then attempt to parse it as JSON. (Calling
|
|
227
|
+
// res.json() then falling back to res.text() does not work: the body stream
|
|
228
|
+
// is already consumed, so the fallback would be empty.)
|
|
229
|
+
bodyText = await res.text();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (controller.signal.aborted) {
|
|
232
|
+
throw new Error(`http source timed out after ${timeoutMs}ms: ${url}`);
|
|
233
|
+
}
|
|
234
|
+
throw err;
|
|
235
|
+
} finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let json;
|
|
240
|
+
try {
|
|
241
|
+
json = JSON.parse(bodyText);
|
|
242
|
+
} catch {
|
|
243
|
+
// Body was not JSON: return the raw text as a single record.
|
|
244
|
+
return [{ text: bodyText }];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const data = source.jq ? jqPath(json, source.jq) : json;
|
|
248
|
+
// A null/undefined selection (e.g. a jq path that matched nothing) yields no
|
|
249
|
+
// records, rather than a junk { text: "undefined" } snippet.
|
|
250
|
+
if (data === undefined || data === null) return [];
|
|
251
|
+
return normalizeToRecords(data);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Case-insensitive check for whether a header is already present.
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} headers
|
|
258
|
+
* @param {string} name
|
|
259
|
+
* @returns {boolean}
|
|
260
|
+
*/
|
|
261
|
+
function hasHeader(headers, name) {
|
|
262
|
+
const lower = name.toLowerCase();
|
|
263
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === lower);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalize an arbitrary value into an array of record objects.
|
|
268
|
+
*
|
|
269
|
+
* @param {*} data
|
|
270
|
+
* @returns {Array<object>}
|
|
271
|
+
*/
|
|
272
|
+
function normalizeToRecords(data) {
|
|
273
|
+
if (Array.isArray(data)) {
|
|
274
|
+
return data.map((el) =>
|
|
275
|
+
isPlainObject(el) ? el : { value: el, text: String(el) }
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (isPlainObject(data)) {
|
|
279
|
+
return [data];
|
|
280
|
+
}
|
|
281
|
+
return [{ value: data, text: String(data) }];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* True for non-null, non-array objects.
|
|
286
|
+
*
|
|
287
|
+
* @param {*} value
|
|
288
|
+
* @returns {boolean}
|
|
289
|
+
*/
|
|
290
|
+
function isPlainObject(value) {
|
|
291
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export default fetchHttp;
|