@wwjd/pi-graphify 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0](https://github.com/WianVDM/pi-graphify/compare/pi-graphify-v0.1.0...pi-graphify-v0.2.0) (2026-07-12)
9
+
10
+
11
+ ### Features
12
+
13
+ * add graphify_status tool and /graphify-status command ([99f809c](https://github.com/WianVDM/pi-graphify/commit/99f809caace328e5af97a5dcdac50af025437776))
14
+ * implement phase 2 backend abstraction ([40afa65](https://github.com/WianVDM/pi-graphify/commit/40afa65bbadf9bcb72676a613d94f6bdb677cc5d))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * correct status tool description and missing-Graphify warning ([c4225c1](https://github.com/WianVDM/pi-graphify/commit/c4225c1591124a65ca0df33565168970a6720a9d))
20
+ * normalize package author email casing ([fcfa352](https://github.com/WianVDM/pi-graphify/commit/fcfa3520463ed860be1264daa6443c608a197444))
21
+
8
22
  ## [Unreleased]
9
23
 
10
24
  ## 0.1.0 (2026-07-11)
@@ -9,23 +9,61 @@
9
9
  */
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import { loadConfig } from "../src/config.js";
12
+ import { GraphifyCoordinator } from "../src/coordinator.js";
13
13
  import { buildGraphifyHint, graphifyStatus } from "../src/graphify.js";
14
14
  import { registerGraphifyTools } from "../src/tools/index.js";
15
15
 
16
16
  export default function (pi: ExtensionAPI) {
17
- const config = loadConfig();
17
+ // Coordinator is created per session and shared by tools and commands.
18
+ let coordinator: GraphifyCoordinator | null = null;
19
+ const getCoordinator = () => coordinator;
18
20
 
19
- // ── 1. Notify and inject context when a graph is present ──────────
21
+ // ── 1. Register tools and commands synchronously ──────────────────
22
+ registerGraphifyTools(pi, getCoordinator);
23
+
24
+ pi.registerCommand("graphify-status", {
25
+ description: "Show Graphify graph status for the current project",
26
+ handler: async (_args, ctx) => {
27
+ const current = getCoordinator();
28
+ if (!current) {
29
+ ctx.ui.notify("Graphify is not initialized.", "warning");
30
+ return;
31
+ }
32
+
33
+ const status = await current.status({ cwd: ctx.cwd });
34
+ if (status.hasGraph && status.graphPath) {
35
+ ctx.ui.notify(`Graphify graph ready: ${status.graphPath}`, "info");
36
+ } else {
37
+ ctx.ui.notify("No Graphify graph found. Run /graphify-build to create one.", "warning");
38
+ }
39
+ },
40
+ });
41
+
42
+ // ── 2. Initialize coordinator per session ─────────────────────────
20
43
  pi.on("session_start", async (_event, ctx) => {
21
- const status = await graphifyStatus(ctx.cwd);
44
+ coordinator = new GraphifyCoordinator({ cwd: ctx.cwd });
45
+ await coordinator.initialize();
46
+
47
+ const status = await coordinator.status({ cwd: ctx.cwd });
22
48
  if (status.hasGraph && status.graphPath) {
23
49
  ctx.ui.notify(`Graphify graph ready: ${status.graphPath}`, "info");
24
50
  }
51
+
52
+ if (coordinator.version === null) {
53
+ ctx.ui.notify(
54
+ "Graphify CLI was not detected on PATH. Install Graphify to use build, query, and other graph tools.",
55
+ "warning",
56
+ );
57
+ }
58
+
59
+ for (const warning of coordinator.warnings) {
60
+ ctx.ui.notify(warning, "warning");
61
+ }
25
62
  });
26
63
 
64
+ // ── 3. Inject context hint when a graph is present ────────────────
27
65
  pi.on("before_agent_start", async (event, ctx) => {
28
- const status = await graphifyStatus(ctx.cwd);
66
+ const status = graphifyStatus(ctx.cwd);
29
67
  if (!status.hasGraph || !status.graphPath) {
30
68
  return;
31
69
  }
@@ -36,19 +74,11 @@ export default function (pi: ExtensionAPI) {
36
74
  };
37
75
  });
38
76
 
39
- // ── 2. Register custom tools the LLM can call ─────────────────────
40
- registerGraphifyTools(pi, config);
41
-
42
- // ── 3. Register slash commands ────────────────────────────────────
43
- pi.registerCommand("graphify-status", {
44
- description: "Show Graphify graph status for the current project",
45
- handler: async (_args, ctx) => {
46
- const status = await graphifyStatus(ctx.cwd);
47
- if (status.hasGraph && status.graphPath) {
48
- ctx.ui.notify(`Graphify graph ready: ${status.graphPath}`, "info");
49
- } else {
50
- ctx.ui.notify("No Graphify graph found. Run /graphify-build to create one.", "warning");
51
- }
52
- },
77
+ // ── 4. Clean up coordinator on shutdown ─────────────────────────
78
+ pi.on("session_shutdown", async () => {
79
+ if (coordinator) {
80
+ await coordinator.close();
81
+ coordinator = null;
82
+ }
53
83
  });
54
84
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@wwjd/pi-graphify",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Graphify knowledge graph integration for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "author": "WIAN <WIANVDM4@GMAIL.COM>",
7
+ "author": "Wian <wianvdm4@gmail.com>",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/WianVDM/pi-graphify.git"
@@ -0,0 +1,201 @@
1
+ /**
2
+ * CLI backend for Graphify.
3
+ *
4
+ * Resolves the `graphify` executable, runs commands via `execFile`, and
5
+ * normalizes the results into the backend-agnostic `GraphifyResult` shape.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { existsSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { buildCapabilities, detectVersion } from "../version.js";
13
+ import type { GraphifyBackend, GraphifyCapabilities, GraphifyResult } from "./types.js";
14
+ import { allCapabilitiesDisabled } from "./types.js";
15
+
16
+ const DEFAULT_TIMEOUT = 30_000;
17
+
18
+ export interface CliBackendOptions {
19
+ cwd: string;
20
+ timeoutMs?: number;
21
+ /** Explicit executable path from config or env. */
22
+ explicitPath?: string;
23
+ }
24
+
25
+ export class CliBackend implements GraphifyBackend {
26
+ readonly type = "cli" as const;
27
+ private readonly cwd: string;
28
+ private readonly timeoutMs: number;
29
+ private readonly explicitPath: string | undefined;
30
+ private _cli: string | null = null;
31
+ private _version: string | null = null;
32
+ private _capabilities: GraphifyCapabilities | null = null;
33
+
34
+ constructor(options: CliBackendOptions) {
35
+ this.cwd = options.cwd;
36
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
37
+ this.explicitPath = options.explicitPath;
38
+ }
39
+
40
+ get version(): string | null {
41
+ return this._version;
42
+ }
43
+
44
+ get capabilities(): GraphifyCapabilities {
45
+ return this._capabilities ?? allCapabilitiesDisabled();
46
+ }
47
+
48
+ async initialize(): Promise<void> {
49
+ this._cli = await this.resolveCli();
50
+ const versionInfo = await detectVersion(this._cli, this.cwd);
51
+ this._version = versionInfo.version;
52
+
53
+ this._capabilities = await buildCapabilities(this._cli, this.cwd, versionInfo, async (args) =>
54
+ this.runHelp(args),
55
+ );
56
+ }
57
+
58
+ async run(operation: string, options: unknown): Promise<GraphifyResult> {
59
+ const cli = await this.resolveCli();
60
+ const args = buildArgs(operation, options);
61
+ const start = performance.now();
62
+
63
+ return new Promise((resolve) => {
64
+ execFile(cli, args, { cwd: this.cwd, timeout: this.timeoutMs }, (error, stdout, stderr) => {
65
+ const durationMs = Math.round(performance.now() - start);
66
+ if (error) {
67
+ resolve({
68
+ stdout: stdout.trim(),
69
+ stderr: stderr.trim() || error.message,
70
+ exitCode: typeof error.code === "number" ? error.code : 1,
71
+ durationMs,
72
+ backend: "cli",
73
+ feature: operation,
74
+ });
75
+ return;
76
+ }
77
+ resolve({
78
+ stdout: stdout.trim(),
79
+ stderr: stderr.trim(),
80
+ exitCode: 0,
81
+ durationMs,
82
+ backend: "cli",
83
+ feature: operation,
84
+ });
85
+ });
86
+ });
87
+ }
88
+
89
+ async close(): Promise<void> {
90
+ // CLI backend has no persistent process to clean up.
91
+ this._cli = null;
92
+ }
93
+
94
+ private async resolveCli(): Promise<string> {
95
+ if (this._cli) return this._cli;
96
+ if (this.explicitPath) return this.explicitPath;
97
+
98
+ // Try the PATH first.
99
+ const fromPath = await findOnPath("graphify");
100
+ if (fromPath) return fromPath;
101
+
102
+ // Common install locations.
103
+ const candidates = [
104
+ join(homedir(), ".local", "bin", "graphify"),
105
+ join(homedir(), ".cargo", "bin", "graphify"),
106
+ join(homedir(), ".pipx", "venvs", "graphify", "bin", "graphify"),
107
+ ];
108
+
109
+ for (const candidate of candidates) {
110
+ if (existsSync(candidate)) return candidate;
111
+ }
112
+
113
+ // Fall back to the bare command and let execFile report the error.
114
+ return "graphify";
115
+ }
116
+
117
+ private async runHelp(args: string[]): Promise<{ stdout: string; exitCode: number }> {
118
+ const cli = await this.resolveCli();
119
+ return new Promise((resolve) => {
120
+ execFile(cli, args, { cwd: this.cwd, timeout: 10_000 }, (error, stdout, _stderr) => {
121
+ if (error) {
122
+ resolve({
123
+ stdout: stdout.trim(),
124
+ exitCode: typeof error.code === "number" ? error.code : 1,
125
+ });
126
+ return;
127
+ }
128
+ resolve({ stdout: stdout.trim(), exitCode: 0 });
129
+ });
130
+ });
131
+ }
132
+ }
133
+
134
+ /** Best-effort find an executable on the user PATH. */
135
+ function findOnPath(command: string): Promise<string | null> {
136
+ const checkCmd = process.platform === "win32" ? "where" : "which";
137
+ const shell = process.platform === "win32" ? "cmd.exe" : undefined;
138
+
139
+ return new Promise((resolve) => {
140
+ execFile(checkCmd, [command], { shell }, (error, stdout) => {
141
+ if (error || !stdout.trim()) {
142
+ resolve(null);
143
+ return;
144
+ }
145
+ resolve(stdout.split("\n")[0].trim());
146
+ });
147
+ });
148
+ }
149
+
150
+ /** Build a CLI argument array from an operation and options. */
151
+ function buildArgs(operation: string, options: unknown): string[] {
152
+ if (!options || typeof options !== "object") {
153
+ return [operation];
154
+ }
155
+
156
+ const opts = options as Record<string, unknown>;
157
+ const args = [operation];
158
+
159
+ if (opts.cwd && typeof opts.cwd === "string") {
160
+ // graphify commands operate on cwd via the spawned process working directory;
161
+ // no explicit flag is needed for most operations.
162
+ }
163
+
164
+ if (opts.codeOnly === true) args.push("--code-only");
165
+ if (opts.update === true) args.push("--update");
166
+ if (opts.directed === true) args.push("--directed");
167
+
168
+ if (opts.question && typeof opts.question === "string") {
169
+ args.push(opts.question);
170
+ }
171
+ if (opts.source && typeof opts.source === "string") {
172
+ args.push("--source", opts.source);
173
+ }
174
+ if (opts.target && typeof opts.target === "string") {
175
+ args.push("--target", opts.target);
176
+ }
177
+ if (opts.node && typeof opts.node === "string") {
178
+ args.push("--node", opts.node);
179
+ }
180
+ if (Array.isArray(opts.files)) {
181
+ for (const file of opts.files) {
182
+ if (typeof file === "string") args.push(file);
183
+ }
184
+ }
185
+ if (Array.isArray(opts.paths)) {
186
+ for (const p of opts.paths) {
187
+ if (typeof p === "string") {
188
+ args.push(resolve(p));
189
+ }
190
+ }
191
+ }
192
+
193
+ return args;
194
+ }
195
+
196
+ /** Create a CLI backend and initialize it. */
197
+ export async function createCliBackend(options: CliBackendOptions): Promise<CliBackend> {
198
+ const backend = new CliBackend(options);
199
+ await backend.initialize();
200
+ return backend;
201
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Backend abstraction types for pi-graphify.
3
+ *
4
+ * All Graphify operations go through the `GraphifyBackend` interface so the
5
+ * rest of the extension is agnostic to whether the CLI or MCP is used.
6
+ */
7
+
8
+ export interface GraphifyCapabilities {
9
+ /** Core graph build. */
10
+ build: boolean;
11
+ /** Query the graph. */
12
+ query: boolean;
13
+ /** Shortest path between nodes. */
14
+ path: boolean;
15
+ /** Explain a node or relationship. */
16
+ explain: boolean;
17
+ /** Affected nodes/files. */
18
+ affected: boolean;
19
+ /** Add content to the graph. */
20
+ add: boolean;
21
+ /** Watch source files for changes. */
22
+ watch: boolean;
23
+ /** Reflect on graph structure. */
24
+ reflect: boolean;
25
+ /** MCP server support. */
26
+ mcp: boolean;
27
+ /** Graph status check. */
28
+ status: boolean;
29
+ /** Version reporting. */
30
+ version: boolean;
31
+ /** Directed graph support. */
32
+ directedGraph: boolean;
33
+ /** Incremental code-only updates. */
34
+ incrementalUpdate: boolean;
35
+ /** Semantic extraction beyond code. */
36
+ semanticExtraction: boolean;
37
+ }
38
+
39
+ /** Normalized result from any Graphify backend. */
40
+ export interface GraphifyResult {
41
+ stdout: string;
42
+ stderr: string;
43
+ exitCode: number;
44
+ durationMs: number;
45
+ backend: "cli" | "mcp";
46
+ feature: string;
47
+ }
48
+
49
+ /** Backend-agnostic interface for all Graphify operations. */
50
+ export interface GraphifyBackend {
51
+ readonly type: "cli" | "mcp";
52
+ readonly version: string | null;
53
+ readonly capabilities: GraphifyCapabilities;
54
+
55
+ /**
56
+ * Run a Graphify operation.
57
+ *
58
+ * @param operation - The operation name, e.g. `"build"`, `"query"`, `"status"`.
59
+ * @param options - Operation-specific options.
60
+ */
61
+ run(operation: string, options: unknown): Promise<GraphifyResult>;
62
+
63
+ /** Optional cleanup hook called on session shutdown. */
64
+ close?(): Promise<void>;
65
+ }
66
+
67
+ /** Options for the `status` operation. */
68
+ export interface StatusOptions {
69
+ cwd: string;
70
+ }
71
+
72
+ /** Options for the `version` operation. */
73
+ export interface VersionOptions {
74
+ cwd?: string;
75
+ }
76
+
77
+ /** Options for the `build` operation. */
78
+ export interface BuildOptions {
79
+ cwd: string;
80
+ codeOnly?: boolean;
81
+ update?: boolean;
82
+ directed?: boolean;
83
+ }
84
+
85
+ /** Options for the `query` operation. */
86
+ export interface QueryOptions {
87
+ cwd: string;
88
+ question: string;
89
+ }
90
+
91
+ /** Options for the `path` operation. */
92
+ export interface PathOptions {
93
+ cwd: string;
94
+ source: string;
95
+ target: string;
96
+ }
97
+
98
+ /** Options for the `explain` operation. */
99
+ export interface ExplainOptions {
100
+ cwd: string;
101
+ node: string;
102
+ }
103
+
104
+ /** Options for the `affected` operation. */
105
+ export interface AffectedOptions {
106
+ cwd: string;
107
+ files: string[];
108
+ }
109
+
110
+ /** Options for the `add` operation. */
111
+ export interface AddOptions {
112
+ cwd: string;
113
+ paths: string[];
114
+ }
115
+
116
+ /** Options for the `watch` operation. */
117
+ export interface WatchOptions {
118
+ cwd: string;
119
+ codeOnly?: boolean;
120
+ }
121
+
122
+ /** Options for the `reflect` operation. */
123
+ export interface ReflectOptions {
124
+ cwd: string;
125
+ question?: string;
126
+ }
127
+
128
+ /** All operation option types unioned together. */
129
+ export type GraphifyOperationOptions =
130
+ | StatusOptions
131
+ | VersionOptions
132
+ | BuildOptions
133
+ | QueryOptions
134
+ | PathOptions
135
+ | ExplainOptions
136
+ | AffectedOptions
137
+ | AddOptions
138
+ | WatchOptions
139
+ | ReflectOptions;
140
+
141
+ export function allCapabilitiesDisabled(): GraphifyCapabilities {
142
+ return {
143
+ build: false,
144
+ query: false,
145
+ path: false,
146
+ explain: false,
147
+ affected: false,
148
+ add: false,
149
+ watch: false,
150
+ reflect: false,
151
+ mcp: false,
152
+ status: false,
153
+ version: false,
154
+ directedGraph: false,
155
+ incrementalUpdate: false,
156
+ semanticExtraction: false,
157
+ };
158
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * GraphifyCoordinator — the single entry point for all Graphify operations.
3
+ *
4
+ * The coordinator loads configuration, selects a backend, detects the installed
5
+ * Graphify version, builds a capability matrix, and routes tool/command
6
+ * requests to the backend. It also normalizes backend results and errors.
7
+ */
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { createCliBackend } from "./backends/cli.js";
12
+ import {
13
+ type AddOptions,
14
+ type AffectedOptions,
15
+ allCapabilitiesDisabled,
16
+ type BuildOptions,
17
+ type ExplainOptions,
18
+ type GraphifyBackend,
19
+ type GraphifyCapabilities,
20
+ type GraphifyResult,
21
+ type PathOptions,
22
+ type QueryOptions,
23
+ type ReflectOptions,
24
+ type StatusOptions,
25
+ type VersionOptions,
26
+ type WatchOptions,
27
+ } from "./backends/types.js";
28
+ import { type GraphifyConfig, loadConfig } from "./config.js";
29
+ import { codeForOperation, GraphifyError, messageForCliFailure } from "./errors.js";
30
+
31
+ export interface GraphifyStatusResult {
32
+ hasGraph: boolean;
33
+ graphPath?: string;
34
+ backendVersion: string | null;
35
+ }
36
+
37
+ export interface CoordinatorOptions {
38
+ cwd: string;
39
+ config?: GraphifyConfig;
40
+ }
41
+
42
+ export class GraphifyCoordinator {
43
+ readonly config: GraphifyConfig;
44
+ readonly cwd: string;
45
+ private backend: GraphifyBackend | null = null;
46
+ private readonly _warnings: string[] = [];
47
+ private closed = false;
48
+
49
+ constructor(options: CoordinatorOptions) {
50
+ this.cwd = options.cwd;
51
+ this.config = options.config ?? loadConfig();
52
+ }
53
+
54
+ async initialize(): Promise<void> {
55
+ if (this.closed) {
56
+ throw new GraphifyError("UNKNOWN", "Coordinator has already been closed");
57
+ }
58
+
59
+ try {
60
+ // Phase 2 only supports CLI backend. MCP is a Phase 6 addition.
61
+ this.backend = await createCliBackend({
62
+ cwd: this.cwd,
63
+ timeoutMs: this.config.queryTimeoutMs,
64
+ });
65
+ } catch (error) {
66
+ this.backend = null;
67
+ this._warnings.push(
68
+ error instanceof Error ? error.message : "Graphify backend failed to initialize",
69
+ );
70
+ }
71
+ }
72
+
73
+ get version(): string | null {
74
+ return this.backend?.version ?? null;
75
+ }
76
+
77
+ get capabilities(): GraphifyCapabilities {
78
+ return this.backend?.capabilities ?? allCapabilitiesDisabled();
79
+ }
80
+
81
+ get warnings(): readonly string[] {
82
+ return this._warnings;
83
+ }
84
+
85
+ supports(feature: keyof GraphifyCapabilities): boolean {
86
+ return this.capabilities[feature] ?? false;
87
+ }
88
+
89
+ async status(_options: StatusOptions): Promise<GraphifyStatusResult> {
90
+ const graphPath = join(this.cwd, "graphify-out", "graph.json");
91
+ return {
92
+ hasGraph: existsSync(graphPath),
93
+ graphPath,
94
+ backendVersion: this.version,
95
+ };
96
+ }
97
+
98
+ async getVersion(_options?: VersionOptions): Promise<GraphifyResult> {
99
+ if (!this.backend) {
100
+ throw new GraphifyError("MISSING", "Graphify is not installed or not available on PATH.");
101
+ }
102
+ return {
103
+ stdout: this.version ?? "unknown",
104
+ stderr: "",
105
+ exitCode: 0,
106
+ durationMs: 0,
107
+ backend: "cli",
108
+ feature: "version",
109
+ };
110
+ }
111
+
112
+ async build(options: BuildOptions): Promise<GraphifyResult> {
113
+ return this.runWithFeature("build", options);
114
+ }
115
+
116
+ async query(options: QueryOptions): Promise<GraphifyResult> {
117
+ return this.runWithFeature("query", options);
118
+ }
119
+
120
+ async path(options: PathOptions): Promise<GraphifyResult> {
121
+ return this.runWithFeature("path", options);
122
+ }
123
+
124
+ async explain(options: ExplainOptions): Promise<GraphifyResult> {
125
+ return this.runWithFeature("explain", options);
126
+ }
127
+
128
+ async affected(options: AffectedOptions): Promise<GraphifyResult> {
129
+ return this.runWithFeature("affected", options);
130
+ }
131
+
132
+ async add(options: AddOptions): Promise<GraphifyResult> {
133
+ return this.runWithFeature("add", options);
134
+ }
135
+
136
+ async watch(options: WatchOptions): Promise<GraphifyResult> {
137
+ return this.runWithFeature("watch", options);
138
+ }
139
+
140
+ async reflect(options: ReflectOptions): Promise<GraphifyResult> {
141
+ return this.runWithFeature("reflect", options);
142
+ }
143
+
144
+ async close(): Promise<void> {
145
+ this.closed = true;
146
+ if (this.backend?.close) {
147
+ await this.backend.close();
148
+ }
149
+ this.backend = null;
150
+ }
151
+
152
+ private async runWithFeature(
153
+ operation: keyof GraphifyCapabilities,
154
+ options: unknown,
155
+ ): Promise<GraphifyResult> {
156
+ if (!this.backend) {
157
+ throw new GraphifyError("MISSING", `Graphify is not available. Cannot run ${operation}.`);
158
+ }
159
+
160
+ if (!this.supports(operation)) {
161
+ throw new GraphifyError(
162
+ "VERSION",
163
+ `The installed Graphify version does not support "${operation}".`,
164
+ );
165
+ }
166
+
167
+ const result = await this.backend.run(operation, options);
168
+ if (result.exitCode !== 0) {
169
+ throw new GraphifyError(
170
+ codeForOperation(operation),
171
+ messageForCliFailure(operation, result.exitCode, result.stderr),
172
+ { exitCode: result.exitCode, stderr: result.stderr },
173
+ );
174
+ }
175
+ return result;
176
+ }
177
+ }
178
+
179
+ /** Convenience factory. */
180
+ export async function createGraphifyCoordinator(
181
+ cwd: string,
182
+ config?: GraphifyConfig,
183
+ ): Promise<GraphifyCoordinator> {
184
+ const coordinator = new GraphifyCoordinator({ cwd, config });
185
+ await coordinator.initialize();
186
+ return coordinator;
187
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Normalized error handling for pi-graphify.
3
+ */
4
+
5
+ export type GraphifyErrorCode =
6
+ | "MISSING"
7
+ | "VERSION"
8
+ | "BUILD"
9
+ | "QUERY"
10
+ | "PATH"
11
+ | "EXPLAIN"
12
+ | "AFFECTED"
13
+ | "ADD"
14
+ | "WATCH"
15
+ | "REFLECT"
16
+ | "TIMEOUT"
17
+ | "MCP"
18
+ | "UNKNOWN";
19
+
20
+ export class GraphifyError extends Error {
21
+ readonly code: GraphifyErrorCode;
22
+ readonly details: Record<string, unknown>;
23
+ readonly userMessage: string;
24
+
25
+ constructor(code: GraphifyErrorCode, userMessage: string, details: Record<string, unknown> = {}) {
26
+ super(userMessage);
27
+ this.code = code;
28
+ this.userMessage = userMessage;
29
+ this.details = details;
30
+ this.name = "GraphifyError";
31
+ }
32
+ }
33
+
34
+ /** Map a Graphify operation name to an error code. */
35
+ export function codeForOperation(operation: string): GraphifyErrorCode {
36
+ switch (operation) {
37
+ case "build":
38
+ return "BUILD";
39
+ case "query":
40
+ return "QUERY";
41
+ case "path":
42
+ return "PATH";
43
+ case "explain":
44
+ return "EXPLAIN";
45
+ case "affected":
46
+ return "AFFECTED";
47
+ case "add":
48
+ return "ADD";
49
+ case "watch":
50
+ return "WATCH";
51
+ case "reflect":
52
+ return "REFLECT";
53
+ case "mcp":
54
+ return "MCP";
55
+ default:
56
+ return "UNKNOWN";
57
+ }
58
+ }
59
+
60
+ /** Build a user-friendly message from a backend result. */
61
+ export function messageForCliFailure(operation: string, exitCode: number, stderr: string): string {
62
+ const trimmed = stderr.trim();
63
+ if (trimmed) {
64
+ return `graphify ${operation} failed (exit ${exitCode}): ${trimmed}`;
65
+ }
66
+ return `graphify ${operation} failed with exit code ${exitCode}`;
67
+ }
package/src/graphify.ts CHANGED
@@ -1,72 +1,23 @@
1
1
  /**
2
- * Graphify CLI helpers
2
+ * High-level Graphify helpers used by the extension and tools.
3
+ *
4
+ * CLI execution has moved to `src/backends/cli.ts`. This module retains:
5
+ * - graph existence checks
6
+ * - system-prompt hint construction
7
+ * - result formatting utilities
3
8
  */
4
9
 
5
- import { execFile } from "node:child_process";
6
10
  import { existsSync } from "node:fs";
7
11
  import { join } from "node:path";
8
- import type { GraphifyConfig } from "./config.js";
9
-
10
- export interface GraphifyResult {
11
- stdout: string;
12
- stderr: string;
13
- exitCode: number;
14
- }
12
+ import type { GraphifyResult } from "./backends/types.js";
15
13
 
16
14
  export interface GraphStatus {
17
15
  hasGraph: boolean;
18
16
  graphPath?: string;
19
- error?: string;
20
- }
21
-
22
- /** Best-effort resolve of the graphify CLI executable name. */
23
- export async function findGraphifyCli(): Promise<string> {
24
- // Pi shells run with the user's PATH, so the bare command is usually enough.
25
- // We try a platform-aware check first; if it fails, we fall back to "graphify".
26
- const checkCmd = process.platform === "win32" ? "where" : "which";
27
- const shell = process.platform === "win32" ? "cmd.exe" : undefined;
28
-
29
- return new Promise((resolve) => {
30
- execFile(checkCmd, ["graphify"], { shell }, (error, stdout) => {
31
- if (error || !stdout.trim()) {
32
- resolve("graphify");
33
- return;
34
- }
35
- // Use the first match returned by where/which.
36
- resolve(stdout.split("\n")[0].trim());
37
- });
38
- });
39
- }
40
-
41
- /** Run a graphify CLI command with a timeout. */
42
- export async function runGraphify(
43
- args: string[],
44
- cwd: string,
45
- timeoutMs: number,
46
- ): Promise<GraphifyResult> {
47
- const cli = await findGraphifyCli();
48
-
49
- return new Promise((resolve) => {
50
- execFile(cli, args, { cwd, timeout: timeoutMs }, (error, stdout, stderr) => {
51
- if (error) {
52
- resolve({
53
- stdout: stdout.trim(),
54
- stderr: stderr.trim() || error.message,
55
- exitCode: typeof error.code === "number" ? error.code : 1,
56
- });
57
- return;
58
- }
59
- resolve({
60
- stdout: stdout.trim(),
61
- stderr: stderr.trim(),
62
- exitCode: 0,
63
- });
64
- });
65
- });
66
17
  }
67
18
 
68
19
  /** Check whether a graph exists for the given project directory. */
69
- export async function graphifyStatus(cwd: string): Promise<GraphStatus> {
20
+ export function graphifyStatus(cwd: string): GraphStatus {
70
21
  const graphPath = join(cwd, "graphify-out", "graph.json");
71
22
 
72
23
  if (!existsSync(graphPath)) {
@@ -94,5 +45,3 @@ export function formatResult(result: GraphifyResult): string {
94
45
  }
95
46
  return `graphify exited with code ${result.exitCode}\n${result.stderr || result.stdout}`.trim();
96
47
  }
97
-
98
- export type { GraphifyConfig };
@@ -3,9 +3,10 @@
3
3
  */
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
- import type { GraphifyConfig } from "../config.js";
7
6
  import { registerStatusTool } from "./status.js";
8
7
 
9
- export function registerGraphifyTools(pi: ExtensionAPI, config: GraphifyConfig): void {
10
- registerStatusTool(pi, config);
8
+ export type CoordinatorProvider = () => import("../coordinator.js").GraphifyCoordinator | null;
9
+
10
+ export function registerGraphifyTools(pi: ExtensionAPI, getCoordinator: CoordinatorProvider): void {
11
+ registerStatusTool(pi, getCoordinator);
11
12
  }
@@ -4,39 +4,62 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import type { GraphifyConfig } from "../config.js";
8
- import { graphifyStatus } from "../graphify.js";
7
+ import type { CoordinatorProvider } from "./index.js";
9
8
 
10
- export function registerStatusTool(pi: ExtensionAPI, _config: GraphifyConfig): void {
9
+ export function registerStatusTool(pi: ExtensionAPI, getCoordinator: CoordinatorProvider): void {
11
10
  pi.registerTool({
12
11
  name: "graphify_status",
13
12
  label: "Graphify Status",
14
- description:
15
- "Check whether a Graphify knowledge graph exists for the current project and whether the graphify CLI is available.",
13
+ description: "Check whether a Graphify knowledge graph exists for the current project.",
16
14
  parameters: Type.Object({}),
17
15
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
18
- const status = await graphifyStatus(ctx.cwd);
16
+ const coordinator = getCoordinator();
17
+ if (!coordinator) {
18
+ return {
19
+ content: [
20
+ {
21
+ type: "text",
22
+ text: "Graphify is not initialized yet.",
23
+ },
24
+ ],
25
+ details: {},
26
+ };
27
+ }
28
+
29
+ const status = await coordinator.status({ cwd: ctx.cwd });
30
+ const details: Record<string, unknown> = {
31
+ hasGraph: status.hasGraph,
32
+ backendVersion: status.backendVersion,
33
+ };
19
34
 
20
35
  if (!status.hasGraph) {
36
+ const cliNote = status.backendVersion ? "" : " The Graphify CLI was not detected on PATH.";
21
37
  return {
22
38
  content: [
23
39
  {
24
40
  type: "text",
25
- text: "No Graphify graph found. Run /graphify-build to build one.",
41
+ text: `No Graphify graph found. Run /graphify-build to build one.${cliNote}`,
26
42
  },
27
43
  ],
28
- details: {},
44
+ details,
29
45
  };
30
46
  }
31
47
 
48
+ const graphText = status.backendVersion
49
+ ? `Graphify graph is available at ${status.graphPath}.`
50
+ : `Graphify graph is available at ${status.graphPath}, but the Graphify CLI was not detected on PATH.`;
51
+
32
52
  return {
33
53
  content: [
34
54
  {
35
55
  type: "text",
36
- text: `Graphify graph is available at ${status.graphPath}.`,
56
+ text: graphText,
37
57
  },
38
58
  ],
39
- details: {},
59
+ details: {
60
+ ...details,
61
+ graphPath: status.graphPath,
62
+ },
40
63
  };
41
64
  },
42
65
  });
package/src/version.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Graphify CLI version detection and capability matrix.
3
+ */
4
+
5
+ import { execFile } from "node:child_process";
6
+ import type { GraphifyCapabilities } from "./backends/types.js";
7
+
8
+ /** Minimum Graphify CLI version considered supported. */
9
+ export const MIN_SUPPORTED_GRAPHIFY = "2.100.0";
10
+
11
+ export interface GraphifyVersionInfo {
12
+ /** Detected version, or null if unknown. */
13
+ version: string | null;
14
+ /** Whether the detected version is at or above the minimum. */
15
+ isSupported: boolean;
16
+ /** Human-readable warnings about version/capability issues. */
17
+ warnings: string[];
18
+ }
19
+
20
+ /** Run a command to detect the Graphify version. */
21
+ export async function detectVersion(cli: string, cwd: string): Promise<GraphifyVersionInfo> {
22
+ const warnings: string[] = [];
23
+
24
+ let version: string | null = null;
25
+ for (const args of [["--version"], ["version"]]) {
26
+ const result = await runProbe(cli, args, cwd, 10_000);
27
+ if (result.exitCode === 0) {
28
+ version = parseVersion(result.stdout);
29
+ if (version) break;
30
+ }
31
+ }
32
+
33
+ if (!version) {
34
+ warnings.push("Could not detect Graphify version; entering best-effort mode.");
35
+ return { version: null, isSupported: false, warnings };
36
+ }
37
+
38
+ const isSupported = isAtLeast(version, MIN_SUPPORTED_GRAPHIFY);
39
+ if (!isSupported) {
40
+ warnings.push(
41
+ `Detected Graphify ${version} is below the supported minimum (${MIN_SUPPORTED_GRAPHIFY}). Some features may be disabled.`,
42
+ );
43
+ }
44
+
45
+ return { version, isSupported, warnings };
46
+ }
47
+
48
+ /** Best-effort parse a version string from CLI output. */
49
+ export function parseVersion(output: string): string | null {
50
+ // Match semver-like strings such as 2.100.0, 2.100.0-alpha, etc.
51
+ const match = output.match(/(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)/);
52
+ return match?.[1] ?? null;
53
+ }
54
+
55
+ /** Compare two semver strings. Returns true if `version` >= `minimum`. */
56
+ export function isAtLeast(version: string, minimum: string): boolean {
57
+ const v = version.split(".").map(Number);
58
+ const m = minimum.split(".").map(Number);
59
+ for (let i = 0; i < Math.max(v.length, m.length); i++) {
60
+ const a = v[i] ?? 0;
61
+ const b = m[i] ?? 0;
62
+ if (a > b) return true;
63
+ if (a < b) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ /** Build a capability matrix from version and runtime help probes. */
69
+ export async function buildCapabilities(
70
+ _cli: string,
71
+ _cwd: string,
72
+ versionInfo: GraphifyVersionInfo,
73
+ runHelp: (args: string[]) => Promise<{ stdout: string; exitCode: number }>,
74
+ ): Promise<GraphifyCapabilities> {
75
+ const caps: GraphifyCapabilities = {
76
+ build: false,
77
+ query: false,
78
+ path: false,
79
+ explain: false,
80
+ affected: false,
81
+ add: false,
82
+ watch: false,
83
+ reflect: false,
84
+ mcp: false,
85
+ status: false,
86
+ version: false,
87
+ directedGraph: false,
88
+ incrementalUpdate: false,
89
+ semanticExtraction: false,
90
+ };
91
+
92
+ // Without a version, we can only safely probe. With a supported version, we
93
+ // still probe because runtime presence is more reliable than version numbers.
94
+ const canProbe = versionInfo.version !== null;
95
+ if (!canProbe) {
96
+ // Best-effort mode: leave everything disabled and let the coordinator warn.
97
+ return caps;
98
+ }
99
+
100
+ // Probe each subcommand. A zero exit on `<cmd> --help` means the command exists.
101
+ const commands = [
102
+ "build",
103
+ "query",
104
+ "path",
105
+ "explain",
106
+ "affected",
107
+ "add",
108
+ "watch",
109
+ "reflect",
110
+ ] as const;
111
+
112
+ for (const cmd of commands) {
113
+ const result = await runHelp([cmd, "--help"]);
114
+ if (result.exitCode === 0) {
115
+ // Map command existence to capability. Some capabilities share a command.
116
+ if (cmd === "build") {
117
+ caps.build = true;
118
+ caps.status = true;
119
+ caps.incrementalUpdate = helpMentions(result.stdout, "--update");
120
+ caps.directedGraph = helpMentions(result.stdout, "--directed");
121
+ } else {
122
+ caps[cmd] = true;
123
+ }
124
+ }
125
+ }
126
+
127
+ // Version is always reported if we can run the binary.
128
+ caps.version = true;
129
+
130
+ // MCP support requires both the CLI and the ability to spawn --mcp.
131
+ const mcpHelp = await runHelp(["--mcp", "--help"]).catch(() => ({ stdout: "", exitCode: 1 }));
132
+ caps.mcp = mcpHelp.exitCode === 0 || helpMentions(mcpHelp.stdout, "--mcp");
133
+
134
+ // Semantic extraction is inferred from help on build or extract commands.
135
+ caps.semanticExtraction =
136
+ caps.build &&
137
+ helpMentions(
138
+ await runHelp(["build", "--help"])
139
+ .then((r) => r.stdout)
140
+ .catch(() => ""),
141
+ ["--semantic", "--extract"],
142
+ );
143
+
144
+ return caps;
145
+ }
146
+
147
+ function helpMentions(help: string, flags: string | string[]): boolean {
148
+ const terms = Array.isArray(flags) ? flags : [flags];
149
+ return terms.some((flag) => help.includes(flag));
150
+ }
151
+
152
+ /** Run a quick probe command. */
153
+ function runProbe(
154
+ cli: string,
155
+ args: string[],
156
+ cwd: string,
157
+ timeout: number,
158
+ ): Promise<{ stdout: string; exitCode: number; stderr: string }> {
159
+ return new Promise((resolve) => {
160
+ execFile(cli, args, { cwd, timeout }, (error, stdout, stderr) => {
161
+ if (error) {
162
+ resolve({
163
+ stdout: stdout.trim(),
164
+ stderr: stderr.trim(),
165
+ exitCode: typeof error.code === "number" ? error.code : 1,
166
+ });
167
+ return;
168
+ }
169
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 });
170
+ });
171
+ });
172
+ }