@pinixai/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinixai/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Clip framework for Pinix — define once, run as CLI / MCP / Pinix bridge",
5
5
  "main": "src/index.ts",
6
6
  "module": "src/index.ts",
@@ -0,0 +1,8 @@
1
+ export interface Binding {
2
+ alias: string;
3
+ hub?: string;
4
+ hub_token?: string;
5
+ clip_token?: string;
6
+ }
7
+
8
+ export type Bindings = Record<string, Binding>;
package/src/cli.ts CHANGED
@@ -93,9 +93,9 @@ function parseScalarValue(value: string, schema: ZodType): unknown {
93
93
  const normalized = unwrapSchema(schema);
94
94
 
95
95
  if (normalized instanceof z.ZodNumber) {
96
- const parsed = Number.parseInt(value, 10);
96
+ const parsed = Number(value);
97
97
 
98
- if (Number.isNaN(parsed)) {
98
+ if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
99
99
  throw new Error(`Invalid number value: ${value}`);
100
100
  }
101
101
 
@@ -111,9 +111,9 @@ function parseScalarValue(value: string, schema: ZodType): unknown {
111
111
  const literalValue = literalValues[0];
112
112
 
113
113
  if (typeof literalValue === "number") {
114
- const parsed = Number.parseInt(value, 10);
114
+ const parsed = Number(value);
115
115
 
116
- if (Number.isNaN(parsed)) {
116
+ if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
117
117
  throw new Error(`Invalid number value: ${value}`);
118
118
  }
119
119
 
package/src/clip.ts CHANGED
@@ -20,9 +20,9 @@ function isHandlerDef(value: unknown): value is HandlerDef {
20
20
  }
21
21
 
22
22
  export abstract class Clip {
23
- abstract name: string;
24
23
  abstract domain: string;
25
24
  abstract patterns: string[];
25
+ dependencies: Record<string, { package: string; version: string }> = {};
26
26
  entities: Record<string, z.ZodObject<any>> = {};
27
27
 
28
28
  protected readonly commands = new Map<string, HandlerDef>();
@@ -52,9 +52,9 @@ export abstract class Clip {
52
52
  return serveHTTP(this);
53
53
  }
54
54
 
55
- const port = Number.parseInt(portArg, 10);
55
+ const port = Number(portArg);
56
56
 
57
- if (Number.isNaN(port)) {
57
+ if (Number.isNaN(port) || !Number.isFinite(port) || port !== Math.floor(port) || port < 0) {
58
58
  console.error(`Invalid port: ${portArg}`);
59
59
  return;
60
60
  }
@@ -101,8 +101,9 @@ export abstract class Clip {
101
101
  }
102
102
 
103
103
  printHelp(): string {
104
+ const name = getClipName(this);
104
105
  const lines: string[] = [
105
- `${this.name} (${this.domain})`,
106
+ name ? `${name} (${this.domain})` : this.domain,
106
107
  "",
107
108
  ];
108
109
 
@@ -187,3 +188,8 @@ export abstract class Clip {
187
188
  }
188
189
  }
189
190
  }
191
+
192
+ export function getClipName(clip: Clip): string | undefined {
193
+ const value = (clip as { name?: unknown }).name;
194
+ return typeof value === "string" && value.length > 0 ? value : undefined;
195
+ }
package/src/handler.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import { z, type ZodType } from "zod";
2
2
 
3
+ export interface Stream {
4
+ chunk(data: unknown): void;
5
+ }
6
+
3
7
  export interface HandlerDef<I extends ZodType = ZodType, O extends ZodType = ZodType> {
4
8
  input: I;
5
9
  output: O;
6
- fn: (input: z.infer<I>) => Promise<z.infer<O>>;
10
+ fn: (input: z.infer<I>, stream?: Stream) => Promise<z.infer<O>>;
7
11
  }
8
12
 
9
13
  export function handler<I extends ZodType, O extends ZodType>(
10
14
  input: I,
11
15
  output: O,
12
- fn: (input: z.infer<I>) => Promise<z.infer<O>>,
16
+ fn: (input: z.infer<I>, stream?: Stream) => Promise<z.infer<O>>,
13
17
  ): HandlerDef<I, O> {
14
18
  return { input, output, fn };
15
19
  }
package/src/http.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { dirname, join, resolve, sep } from "node:path";
2
- import type { Clip } from "./clip";
2
+ import { getClipName, type Clip } from "./clip";
3
3
  import { zodToManifestType } from "./manifest";
4
4
 
5
5
  const CORS_HEADERS = {
@@ -249,5 +249,5 @@ export async function serveHTTP(clip: Clip, port = 3000): Promise<void> {
249
249
  fetch: (request) => handleRequest(clip, request),
250
250
  });
251
251
 
252
- console.error(`Clip "${clip.name}" running at http://localhost:${server.port}`);
252
+ console.error(`Clip "${getClipName(clip) ?? clip.constructor.name}" running at http://localhost:${server.port}`);
253
253
  }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { Clip } from "./clip";
2
+ export type { Binding, Bindings } from "./bindings";
2
3
  export { command } from "./command";
3
- export { handler, type HandlerDef } from "./handler";
4
+ export { handler, type HandlerDef, type Stream } from "./handler";
4
5
  export { serveHTTP } from "./http";
5
- export { serveIPC, invoke } from "./ipc";
6
+ export { serveIPC, invoke, redirectConsoleToStderr } from "./ipc";
7
+ export type { IPCManifest } from "./manifest";
6
8
  export { serveMCP } from "./mcp";
7
9
  export { z } from "zod";
package/src/ipc.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import type { Binding, Bindings } from "./bindings";
1
5
  import type { Clip } from "./clip";
2
- import { createIPCManifest } from "./manifest";
6
+ import type { Stream } from "./handler";
7
+ import { createIPCManifest, type IPCManifest } from "./manifest";
3
8
 
4
9
  // === IPC Protocol Types ===
5
10
 
@@ -12,7 +17,12 @@ interface BaseMessage {
12
17
 
13
18
  interface RegisterMessage extends BaseMessage {
14
19
  type: "register";
15
- manifest: { name: string; domain: string; commands: string[]; dependencies: string[] };
20
+ manifest: IPCManifest;
21
+ }
22
+
23
+ interface RegisteredMessage extends BaseMessage {
24
+ type: "registered";
25
+ alias?: string;
16
26
  }
17
27
 
18
28
  interface InvokeMessage extends BaseMessage {
@@ -21,6 +31,9 @@ interface InvokeMessage extends BaseMessage {
21
31
  command?: string;
22
32
  clip?: string;
23
33
  input?: unknown;
34
+ hub?: string;
35
+ hub_token?: string;
36
+ clip_token?: string;
24
37
  }
25
38
 
26
39
  interface ResultMessage extends BaseMessage {
@@ -35,10 +48,83 @@ interface ErrorMessage extends BaseMessage {
35
48
  error: string;
36
49
  }
37
50
 
51
+ interface ChunkMessage extends BaseMessage {
52
+ type: "chunk";
53
+ id: string;
54
+ output: unknown;
55
+ }
56
+
57
+ interface DoneMessage extends BaseMessage {
58
+ type: "done";
59
+ id: string;
60
+ }
61
+
38
62
  // === State ===
39
63
 
40
64
  const pendingInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
41
65
  let idCounter = 0;
66
+ let registeredAlias: string | undefined;
67
+ const bindings = loadBindings();
68
+
69
+ function loadBindings(): Bindings {
70
+ const bindingsPath = join(dirname(Bun.main), "bindings.json");
71
+
72
+ try {
73
+ const parsed = JSON.parse(readFileSync(bindingsPath, "utf8")) as unknown;
74
+
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
76
+ return {};
77
+ }
78
+
79
+ const loadedBindings: Bindings = {};
80
+ for (const [slot, value] of Object.entries(parsed)) {
81
+ const binding = normalizeBinding(value);
82
+ if (binding) {
83
+ loadedBindings[slot] = binding;
84
+ }
85
+ }
86
+
87
+ return loadedBindings;
88
+ } catch {
89
+ return {};
90
+ }
91
+ }
92
+
93
+ function normalizeBinding(value: unknown): Binding | null {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
95
+ return null;
96
+ }
97
+
98
+ const candidate = value as Record<string, unknown>;
99
+ const alias = asNonEmptyString(candidate.alias);
100
+
101
+ if (!alias) {
102
+ return null;
103
+ }
104
+
105
+ const binding: Binding = { alias };
106
+ const hub = asNonEmptyString(candidate.hub);
107
+ const hubToken = asNonEmptyString(candidate.hub_token);
108
+ const clipToken = asNonEmptyString(candidate.clip_token);
109
+
110
+ if (hub) {
111
+ binding.hub = hub;
112
+ }
113
+
114
+ if (hubToken) {
115
+ binding.hub_token = hubToken;
116
+ }
117
+
118
+ if (clipToken) {
119
+ binding.clip_token = clipToken;
120
+ }
121
+
122
+ return binding;
123
+ }
124
+
125
+ function asNonEmptyString(value: unknown): string | undefined {
126
+ return typeof value === "string" && value.length > 0 ? value : undefined;
127
+ }
42
128
 
43
129
  function nextId(): string {
44
130
  return `c${++idCounter}`;
@@ -50,22 +136,41 @@ function send(msg: Record<string, unknown>): void {
50
136
 
51
137
  // === Public API ===
52
138
 
53
- export async function invoke(clip: string, command: string, input: unknown): Promise<unknown> {
139
+ export async function invoke(slot: string, command: string, input: unknown): Promise<unknown> {
140
+ const binding = bindings[slot];
54
141
  const id = nextId();
142
+
55
143
  return new Promise((resolve, reject) => {
56
144
  pendingInvokes.set(id, { resolve, reject });
57
- send({ id, type: "invoke", clip, command, input });
145
+ send({
146
+ id,
147
+ type: "invoke",
148
+ clip: binding?.alias ?? slot,
149
+ command,
150
+ input,
151
+ hub: binding?.hub,
152
+ hub_token: binding?.hub_token,
153
+ clip_token: binding?.clip_token,
154
+ });
58
155
  });
59
156
  }
60
157
 
158
+ // === Stdout Protection ===
159
+
160
+ export function redirectConsoleToStderr(): void {
161
+ const write = (...args: unknown[]) => {
162
+ process.stderr.write(args.map(String).join(" ") + "\n");
163
+ };
164
+ console.log = write;
165
+ console.info = write;
166
+ console.debug = write;
167
+ }
168
+
61
169
  // === IPC Server ===
62
170
 
63
171
  export async function serveIPC(clip: Clip): Promise<void> {
64
172
  // Redirect console.log to stderr so stdout is reserved for IPC
65
- const origLog = console.log;
66
- console.log = (...args: unknown[]) => {
67
- process.stderr.write(args.map(String).join(" ") + "\n");
68
- };
173
+ redirectConsoleToStderr();
69
174
 
70
175
  // Register with pinixd
71
176
  const manifest = createIPCManifest(clip);
@@ -91,7 +196,7 @@ export async function serveIPC(clip: Clip): Promise<void> {
91
196
 
92
197
  switch (msg.type) {
93
198
  case "registered":
94
- // Registration confirmed
199
+ registeredAlias = asNonEmptyString((msg as RegisteredMessage).alias);
95
200
  break;
96
201
 
97
202
  case "invoke": {
@@ -120,10 +225,32 @@ export async function serveIPC(clip: Clip): Promise<void> {
120
225
  break;
121
226
  }
122
227
 
228
+ case "chunk": {
229
+ // Streaming chunks — currently ignored on client side
230
+ // Future: could accumulate or forward to a stream callback
231
+ break;
232
+ }
233
+
234
+ case "done": {
235
+ const done = msg as DoneMessage;
236
+ const pending = pendingInvokes.get(done.id);
237
+ if (pending) {
238
+ pendingInvokes.delete(done.id);
239
+ pending.resolve(undefined);
240
+ }
241
+ break;
242
+ }
243
+
123
244
  default:
124
245
  process.stderr.write(`[ipc] unknown message type: ${msg.type}\n`);
125
246
  }
126
247
  }
248
+
249
+ // Clean up pending invokes on EOF
250
+ for (const [id, pending] of pendingInvokes) {
251
+ pending.reject(new Error("IPC connection closed"));
252
+ }
253
+ pendingInvokes.clear();
127
254
  }
128
255
 
129
256
  async function handleInvoke(
@@ -137,9 +264,24 @@ async function handleInvoke(
137
264
  }
138
265
 
139
266
  try {
140
- const parsed = cmd.input.parse(msg.input ?? {});
141
- const output = await cmd.fn(parsed);
142
- send({ id: msg.id, type: "result", output });
267
+ const parsed = await cmd.input.parseAsync(msg.input ?? {});
268
+ let streamed = false;
269
+ const stream: Stream = {
270
+ chunk(data: unknown): void {
271
+ streamed = true;
272
+ send({ id: msg.id, type: "chunk", output: data });
273
+ },
274
+ };
275
+
276
+ const output = await cmd.fn(parsed, stream);
277
+
278
+ if (streamed) {
279
+ send({ id: msg.id, type: "done" });
280
+ return;
281
+ }
282
+
283
+ const validatedOutput = await cmd.output.parseAsync(output);
284
+ send({ id: msg.id, type: "result", output: validatedOutput });
143
285
  } catch (err) {
144
286
  send({ id: msg.id, type: "error", error: err instanceof Error ? err.message : String(err) });
145
287
  }
@@ -148,14 +290,17 @@ async function handleInvoke(
148
290
  // === Line Reader ===
149
291
 
150
292
  async function* createLineReader(stream: NodeJS.ReadableStream): AsyncGenerator<string> {
293
+ const decoder = new StringDecoder("utf8");
151
294
  let buffer = "";
152
295
  for await (const chunk of stream) {
153
- buffer += chunk.toString();
296
+ buffer += decoder.write(chunk as Buffer);
154
297
  const lines = buffer.split("\n");
155
298
  buffer = lines.pop() ?? "";
156
299
  for (const line of lines) {
157
300
  if (line.trim()) yield line;
158
301
  }
159
302
  }
303
+ const remaining = decoder.end();
304
+ if (remaining) buffer += remaining;
160
305
  if (buffer.trim()) yield buffer;
161
306
  }
package/src/manifest.ts CHANGED
@@ -1,11 +1,15 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
1
3
  import { z, type ZodType } from "zod";
2
- import type { Clip } from "./clip";
4
+ import { getClipName, type Clip } from "./clip";
3
5
 
4
6
  export interface IPCManifest {
5
- name: string;
6
7
  domain: string;
8
+ description?: string;
7
9
  commands: string[];
8
- dependencies: string[];
10
+ dependencies: Record<string, { package: string; version: string }>;
11
+ package?: string;
12
+ version?: string;
9
13
  }
10
14
 
11
15
  function formatLiteralValue(value: unknown): string {
@@ -94,11 +98,8 @@ export function zodToManifestType(schema: ZodType): string {
94
98
  }
95
99
 
96
100
  export function generateManifest(clip: Clip): string {
97
- const lines = [
98
- `Clip: ${clip.name}`,
99
- `Domain: ${clip.domain}`,
100
- "",
101
- ];
101
+ const name = getClipName(clip);
102
+ const lines = name ? [`Clip: ${name}`, `Domain: ${clip.domain}`, ""] : [`Domain: ${clip.domain}`, ""];
102
103
 
103
104
  if (clip.patterns.length > 0) {
104
105
  lines.push("Patterns:");
@@ -147,15 +148,45 @@ export function generateManifest(clip: Clip): string {
147
148
  return lines.join("\n");
148
149
  }
149
150
 
151
+ function findJsonFile(filename: string): Record<string, unknown> | null {
152
+ let dir = dirname(Bun.main);
153
+ for (;;) {
154
+ const filePath = join(dir, filename);
155
+ if (existsSync(filePath)) {
156
+ try {
157
+ return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+ const parent = dirname(dir);
163
+ if (parent === dir) break;
164
+ dir = parent;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ function resolvePackageInfo(): { package?: string; version?: string } {
170
+ const pinixJson = findJsonFile("pinix.json");
171
+ const packageJson = findJsonFile("package.json");
172
+
173
+ return {
174
+ package: asString(pinixJson?.name) ?? asString(packageJson?.name),
175
+ version: asString(pinixJson?.version) ?? asString(packageJson?.version),
176
+ };
177
+ }
178
+
179
+ function asString(value: unknown): string | undefined {
180
+ return typeof value === "string" && value.length > 0 ? value : undefined;
181
+ }
182
+
150
183
  export function createIPCManifest(clip: Clip): IPCManifest {
151
- const dependencies = (clip as Clip & { dependencies?: unknown }).dependencies;
184
+ const pkgInfo = resolvePackageInfo();
152
185
 
153
186
  return {
154
- name: clip.name,
155
187
  domain: clip.domain,
156
188
  commands: Array.from(clip.getCommands().keys()),
157
- dependencies: Array.isArray(dependencies)
158
- ? dependencies.filter((dependency): dependency is string => typeof dependency === "string")
159
- : [],
189
+ dependencies: clip.dependencies,
190
+ ...pkgInfo,
160
191
  };
161
192
  }
package/src/mcp.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import type { Clip } from "./clip";
3
+ import { getClipName, type Clip } from "./clip";
4
+ import { redirectConsoleToStderr } from "./ipc";
4
5
 
5
6
  export async function serveMCP(clip: Clip): Promise<void> {
7
+ // Redirect console.log to stderr so stdout is reserved for MCP JSON-RPC
8
+ redirectConsoleToStderr();
6
9
  const server = new McpServer({
7
- name: clip.name,
10
+ name: getClipName(clip) ?? clip.constructor.name,
8
11
  version: "1.0.0",
9
12
  });
10
13