@cadcamfunv2/cadcamfun-nikcli-plugin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @cadcamfunv2/cadcamfun-nikcli-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A standalone [nikcli](https://github.com/) (terminal, another machine, CI) driving a **running CADCAMFUN**: the application's CAD/CAM
4
+ action catalogue as nikcli tools. Same tool ids and descriptions as the in-app assistant (`cad`, `cad_edit`, `cam`, `cam_edit`,
5
+ `library`, `parts`, `library_edit`, `memory`, `memory_edit`, `research`, `vcs`, `vcs_edit`), each carrying its actions behind `action`.
6
+
7
+ The list is read from the running application (`GET /api/ai/actions`), so the plugin never holds a second copy of the catalogue.
8
+ Calls go to `POST /api/ai/actions/invoke` and run the very tool definitions the embedded host registers, as the account that holds
9
+ the token, in the project you name.
10
+
11
+ ## Use
12
+
13
+ ```jsonc
14
+ // nikcli config
15
+ { "plugin": [["file:///path/to/packages/nikcli-plugin/src/index.ts", { "url": "http://127.0.0.1:43111", "token": "<launch token>", "projectId": "<project id>" }]] }
16
+ ```
17
+
18
+ or `CADCAMFUN_URL`, `CADCAMFUN_TOKEN` and `CADCAMFUN_PROJECT` in the environment. Create the token in the application (Settings → AI → Standalone nikcli): it is for the open project, read-only or read-write, opens only
19
+ the action bridge, expires, and can be revoked there. (The application's own launch token also works and opens everything; prefer a scoped one.)
20
+
21
+ ## What it will not do
22
+
23
+ - **Delete or replace work** (`cad_edit` `delete`, `library_edit` `delete`, `vcs_edit` `restore`, ...): in the application the host asks
24
+ a person first, an outside client has no such prompt, and a `confirm: true` in the arguments is not an approval. The refusal says so.
25
+ - **Delegate** to the specialist agents (`agent`): they run model sessions inside the application.
26
+ - **Go beyond its scope**: a bridge token is for one project and, when read-only, reaches only read actions. The operator can narrow
27
+ further with nikcli policy statements in `<data dir>/ai/policy.json` (`bridge.use`, `tool.use`).
28
+
29
+ ## Compatibility
30
+
31
+ `test/compat.test.ts` reads the shapes this package relies on (`Plugin`, `PluginModule`, `Hooks.tool`, `tool()`) from
32
+ `@nikcli-ai/plugin`'s declarations in the repo's `.nikcli/` folder. `apps/sidecar/test/nikcli-plugin.test.ts` runs the plugin against the
33
+ real sidecar routes.
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@cadcamfunv2/cadcamfun-nikcli-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Drive a running CADCAMFUN instance from a standalone nikcli: the application's CAD/CAM action catalogue as nikcli tools.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nikomatt69/cadcamfun.git",
9
+ "directory": "packages/nikcli-plugin"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org/"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "bun check -p tsconfig.json"
24
+ },
25
+ "dependencies": {
26
+ "zod": "^4.1.8"
27
+ }
28
+ }
package/src/client.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The HTTP face of the application, as a standalone nikcli reaches it: `GET /api/ai/actions` for the catalogue and
3
+ * `POST /api/ai/actions/invoke` for a call. The bearer token is the one the application's own windows use; it is sent on every
4
+ * request and never logged or put in an error.
5
+ */
6
+ export interface BridgeTool {
7
+ readonly id: string
8
+ readonly description: string
9
+ readonly kind: "read" | "write" | "destructive"
10
+ readonly actions: ReadonlyArray<{ readonly id: string; readonly name: string; readonly kind: "read" | "write" | "destructive" }>
11
+ readonly parameters: Record<string, unknown>
12
+ }
13
+
14
+ export interface BridgeOptions {
15
+ /** Base URL of the running sidecar, e.g. `http://127.0.0.1:43111`. */
16
+ readonly url: string
17
+ readonly token: string
18
+ /** Defaults to the global `fetch`; tests and unusual transports inject their own. */
19
+ readonly fetch?: (input: string, init?: RequestInit) => Promise<Response>
20
+ }
21
+
22
+ export class BridgeError extends Error {
23
+ constructor(message: string, readonly status: number) { super(message) }
24
+ }
25
+
26
+ const messageOf = async (response: Response): Promise<string> => {
27
+ const text = await response.text().catch(() => "")
28
+ try {
29
+ const body = JSON.parse(text) as { message?: unknown; error?: unknown }
30
+ if (typeof body.message === "string") return body.message
31
+ if (typeof body.error === "string") return body.error
32
+ } catch { /* not JSON */ }
33
+ return text.slice(0, 300) || `HTTP ${response.status}`
34
+ }
35
+
36
+ export class BridgeClient {
37
+ readonly #base: string
38
+ readonly #fetch: (input: string, init?: RequestInit) => Promise<Response>
39
+ readonly #token: string
40
+ constructor(options: BridgeOptions) {
41
+ this.#base = options.url.replace(/\/+$/, "")
42
+ this.#token = options.token
43
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init))
44
+ }
45
+
46
+ async #request(path: string, init: RequestInit = {}): Promise<Response> {
47
+ const response = await this.#fetch(`${this.#base}${path}`, { ...init, headers: { ...(init.headers as Record<string, string> | undefined), authorization: `Bearer ${this.#token}`, ...(init.body ? { "content-type": "application/json" } : {}) } })
48
+ if (response.status === 401) throw new BridgeError("CADCAMFUN refused the token (401). Use the token the running application was launched with.", 401)
49
+ return response
50
+ }
51
+
52
+ async tools(): Promise<ReadonlyArray<BridgeTool>> {
53
+ const response = await this.#request("/api/ai/actions")
54
+ if (!response.ok) throw new BridgeError(`Could not read the CADCAMFUN catalogue: ${await messageOf(response)}`, response.status)
55
+ return ((await response.json()) as { tools: ReadonlyArray<BridgeTool> }).tools
56
+ }
57
+
58
+ /** Runs one action. A refusal by the application (400) is returned as `{ ok: false, error }`, so the agent can read it and recover. */
59
+ async invoke(projectId: string, tool: string, input: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
60
+ const response = await this.#request("/api/ai/actions/invoke", { method: "POST", body: JSON.stringify({ projectId, tool, input }), ...(signal ? { signal } : {}) })
61
+ if (response.status === 400) return { ok: false, error: await messageOf(response) }
62
+ if (!response.ok) throw new BridgeError(`CADCAMFUN could not run ${tool}: ${await messageOf(response)}`, response.status)
63
+ return ((await response.json()) as { result: unknown }).result
64
+ }
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `@cadcamfunv2/cadcamfun-nikcli-plugin` (RM-11, spec 27 §4.4): the application's CAD/CAM action catalogue as tools of a standalone nikcli.
3
+ *
4
+ * Install it in nikcli's config next to a running CADCAMFUN:
5
+ *
6
+ * { "plugin": [["file:///path/to/packages/nikcli-plugin/src/index.ts", { "url": "http://127.0.0.1:PORT", "token": "...", "projectId": "..." }]] }
7
+ *
8
+ * (or `CADCAMFUN_URL`, `CADCAMFUN_TOKEN`, `CADCAMFUN_PROJECT` in the environment). nikcli then has the same tool ids as the in-app
9
+ * assistant (`cad`, `cad_edit`, `cam`, `cam_edit`, `library`, `memory`, `research`, `vcs`, `vcs_edit`, ...), each carrying its
10
+ * actions behind `action`, with the application's own descriptions. The list is read from the running application, so the plugin
11
+ * never carries a second copy of the catalogue: a new action there is a new action here.
12
+ *
13
+ * What it will not do, by design: delete or replace work (the application asks a person first; an outside client has no such
14
+ * prompt) and delegate to the specialist agents (those run inside the application).
15
+ */
16
+ import { BridgeClient, type BridgeOptions, type BridgeTool } from "./client.ts"
17
+ import { toolShape, type Zod } from "./schema.ts"
18
+
19
+ export { BridgeClient, BridgeError, type BridgeTool } from "./client.ts"
20
+ export { toZod, toolShape } from "./schema.ts"
21
+
22
+ /** nikcli's `tool()` helper and the zod it validates with (`@nikcli-ai/plugin/tool`); injected so nikcli's own copy is used. */
23
+ export interface ToolFactory {
24
+ (input: { description: string; args: Record<string, unknown>; execute: (args: any, context: any) => Promise<unknown> }): unknown
25
+ schema: Zod
26
+ }
27
+
28
+ export interface CadcamPluginOptions extends BridgeOptions {
29
+ /** The project the tools act on, unless a call names another with `projectId`. */
30
+ readonly projectId?: string
31
+ }
32
+
33
+ const textOf = (value: unknown): string => typeof value === "string" ? value : JSON.stringify(value) ?? "null"
34
+
35
+ /** One nikcli tool per catalogue tool of the running application. */
36
+ export const createCadcamTools = async (options: CadcamPluginOptions & { readonly tool: ToolFactory }): Promise<Record<string, unknown>> => {
37
+ const client = new BridgeClient(options)
38
+ const z = options.tool.schema
39
+ const tools: ReadonlyArray<BridgeTool> = await client.tools()
40
+ return Object.fromEntries(tools.map((entry) => {
41
+ const { shape, summary } = toolShape(entry.parameters, z)
42
+ const refused = entry.actions.filter((action) => action.kind === "destructive").map((action) => action.name)
43
+ const description = [
44
+ entry.description.trim(),
45
+ summary ? `\nActions (required fields): ${summary}.` : "",
46
+ refused.length > 0 ? `\nNot available from outside the application (they need a person's approval there): ${refused.join(", ")}.` : "",
47
+ "\nThis tool runs in the open CADCAMFUN project; pass projectId to act on another."
48
+ ].join("")
49
+ return [entry.id, options.tool({
50
+ description,
51
+ args: { ...shape, projectId: z.string().optional().describe("The CADCAMFUN project id; defaults to the plugin's project") },
52
+ execute: async (args: Record<string, unknown>, context: { abort?: AbortSignal }) => {
53
+ const { projectId: named, ...input } = args
54
+ const projectId = typeof named === "string" && named !== "" ? named : options.projectId
55
+ if (!projectId) return JSON.stringify({ ok: false, error: "No CADCAMFUN project: set the plugin's projectId option (or CADCAMFUN_PROJECT) or pass projectId." })
56
+ try { return textOf(await client.invoke(projectId, entry.id, input, context?.abort)) }
57
+ catch (cause) { return JSON.stringify({ ok: false, error: cause instanceof Error ? cause.message : String(cause) }) }
58
+ }
59
+ })]
60
+ }))
61
+ }
62
+
63
+ const fromEnvironment = (options: Record<string, unknown> = {}): CadcamPluginOptions => {
64
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {}
65
+ const url = String(options["url"] ?? env["CADCAMFUN_URL"] ?? "")
66
+ const token = String(options["token"] ?? env["CADCAMFUN_TOKEN"] ?? "")
67
+ if (!url || !token) throw new Error("@cadcamfunv2/cadcamfun-nikcli-plugin needs the running application's url and token (plugin options, or CADCAMFUN_URL and CADCAMFUN_TOKEN).")
68
+ const projectId = options["projectId"] ?? env["CADCAMFUN_PROJECT"]
69
+ return { url, token, ...(typeof projectId === "string" && projectId !== "" ? { projectId } : {}) }
70
+ }
71
+
72
+ /** The nikcli server plugin: `{ tool }` hook with the catalogue's tools. */
73
+ export const CadcamPlugin = async (_input: unknown, options?: Record<string, unknown>): Promise<{ tool: Record<string, unknown> }> => {
74
+ // The configuration is checked first, so a missing url or token is reported as that and not as a missing SDK.
75
+ const configuration = fromEnvironment(options)
76
+ // nikcli supplies the SDK; a variable specifier keeps this package free of a dependency on it (and of its types).
77
+ const specifier = "@nikcli-ai/plugin/tool"
78
+ const { tool } = (await import(specifier)) as { tool: unknown }
79
+ return { tool: await createCadcamTools({ ...configuration, tool: tool as unknown as ToolFactory }) }
80
+ }
81
+
82
+ export default { id: "cadcamfun", server: CadcamPlugin }
package/src/schema.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * JSON Schema -> zod, for the subset the catalogue's tool schemas use (`z.toJSONSchema` output: objects, arrays, enums, consts,
3
+ * unions, primitives, local `$ref`s). The zod namespace is injected: nikcli validates tool arguments with its own copy of zod, and
4
+ * a schema built with another copy is not one it can read.
5
+ *
6
+ * A tool's arguments are a union over its actions, discriminated by `action`. nikcli wants an object shape, so `toolShape`
7
+ * flattens the union: `action` is the enum of the actions and every other field of any action is optional. That is a hint for
8
+ * the model, not the validation: CADCAMFUN validates each call against the action's own schema and answers a refusal as a result.
9
+ */
10
+ export type Json = { readonly [key: string]: unknown }
11
+ /** The part of zod's API used here; `any` because the namespace is the caller's copy. */
12
+ export type Zod = any
13
+
14
+ const isRecord = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value)
15
+
16
+ export const toZod = (schema: unknown, z: Zod, defs: Json = {}, depth = 0): any => {
17
+ if (!isRecord(schema) || depth > 12) return z.any()
18
+ const root = isRecord(schema["$defs"]) ? { ...defs, ...schema["$defs"] } : defs
19
+ const describe = (type: any) => typeof schema["description"] === "string" ? type.describe(schema["description"]) : type
20
+ const ref = schema["$ref"]
21
+ if (typeof ref === "string") {
22
+ const target = root[ref.replace(/^#\/\$defs\//, "")]
23
+ return target === undefined ? z.any() : toZod(target, z, root, depth + 1)
24
+ }
25
+ if ("const" in schema) return describe(z.literal(schema["const"] as never))
26
+ if (Array.isArray(schema["enum"])) {
27
+ const values = schema["enum"] as unknown[]
28
+ if (values.length > 0 && values.every((value) => typeof value === "string")) return describe(z.enum(values as [string, ...string[]]))
29
+ return describe(z.union(values.map((value) => z.literal(value as never)) as never))
30
+ }
31
+ const variants = (schema["anyOf"] ?? schema["oneOf"]) as unknown
32
+ if (Array.isArray(variants)) {
33
+ const members = variants.map((member) => toZod(member, z, root, depth + 1))
34
+ return describe(members.length === 1 ? members[0] : members.length === 0 ? z.any() : z.union(members))
35
+ }
36
+ const type = schema["type"]
37
+ if (Array.isArray(type)) return describe(z.union(type.map((t) => toZod({ ...schema, type: t }, z, root, depth + 1))))
38
+ switch (type) {
39
+ case "string": return describe(z.string())
40
+ case "number": return describe(z.number())
41
+ case "integer": return describe(z.number().int())
42
+ case "boolean": return describe(z.boolean())
43
+ case "null": return describe(z.null())
44
+ case "array": return describe(z.array(toZod(schema["items"], z, root, depth + 1)))
45
+ case "object": {
46
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {}
47
+ const required = new Set(Array.isArray(schema["required"]) ? (schema["required"] as string[]) : [])
48
+ if (Object.keys(properties).length === 0 && isRecord(schema["additionalProperties"])) return describe(z.record(z.string(), toZod(schema["additionalProperties"], z, root, depth + 1)))
49
+ const shape = Object.fromEntries(Object.entries(properties).map(([key, value]) => {
50
+ const member = toZod(value, z, root, depth + 1)
51
+ return [key, required.has(key) ? member : member.optional()]
52
+ }))
53
+ return describe(z.object(shape).passthrough ? z.object(shape).passthrough() : z.object(shape))
54
+ }
55
+ default: return z.any()
56
+ }
57
+ }
58
+
59
+ export interface ToolShape {
60
+ /** The args object nikcli's `tool()` takes. */
61
+ readonly shape: Record<string, any>
62
+ readonly actions: ReadonlyArray<string>
63
+ /** `action: field, field` for each action, to append to the tool's description. */
64
+ readonly summary: string
65
+ }
66
+
67
+ /** Flattens a tool's per-action union into one object shape (see the module note). */
68
+ export const toolShape = (parameters: unknown, z: Zod): ToolShape => {
69
+ const root = isRecord(parameters) ? parameters : {}
70
+ const defs = isRecord(root["$defs"]) ? root["$defs"] : {}
71
+ const variants = (Array.isArray(root["anyOf"]) ? root["anyOf"] : Array.isArray(root["oneOf"]) ? root["oneOf"] : [root]).filter(isRecord)
72
+ const actions: string[] = []
73
+ const fields = new Map<string, Array<{ readonly schema: unknown; readonly required: boolean }>>()
74
+ const summary: string[] = []
75
+ for (const variant of variants) {
76
+ const properties = isRecord(variant["properties"]) ? variant["properties"] : {}
77
+ const required = new Set(Array.isArray(variant["required"]) ? (variant["required"] as string[]) : [])
78
+ const action = isRecord(properties["action"]) ? properties["action"] : undefined
79
+ const names = action === undefined ? [] : "const" in action ? [String(action["const"])] : Array.isArray(action["enum"]) ? (action["enum"] as unknown[]).map(String) : []
80
+ actions.push(...names)
81
+ const needs = [...required].filter((key) => key !== "action")
82
+ for (const name of names) summary.push(`${name}${needs.length > 0 ? ` (${needs.join(", ")})` : ""}`)
83
+ for (const [key, schema] of Object.entries(properties)) {
84
+ if (key === "action") continue
85
+ fields.set(key, [...(fields.get(key) ?? []), { schema, required: required.has(key) }])
86
+ }
87
+ }
88
+ const shape: Record<string, any> = {}
89
+ if (actions.length > 0) shape["action"] = z.enum([...new Set(actions)] as [string, ...string[]]).describe("Which action of the tool to run")
90
+ for (const [key, uses] of fields) {
91
+ const distinct = new Set(uses.map((use) => JSON.stringify(use.schema)))
92
+ shape[key] = (distinct.size === 1 ? toZod(uses[0]!.schema, z, defs) : z.any()).optional()
93
+ }
94
+ return { shape, actions: [...new Set(actions)], summary: summary.join("; ") }
95
+ }