Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ curl -fsSL https://raw.githubusercontent.com/pablozaiden/link/main/install.sh |

The installer downloads the latest release for your platform and installs it as `link-cli` in `$HOME/.local/bin`. If that directory is not on your `PATH`, the installer prints the shell profile line to add.

Installed binaries can check for or install release updates:

```bash
link-cli update --check
link-cli update
link-cli update --version 0.1.0
```

The update command works from an installed `link-cli` binary. When running from source, use the installer or download a release binary instead.

## Local development

```bash
Expand All @@ -47,19 +57,21 @@ bun run build

## CLI usage

Running `link-cli` with no arguments shows the available top-level commands:
Running `link-cli` with no arguments shows the current CLI version and available top-level commands:

```bash
link-cli
link-cli web
link-cli validate
link-cli seed
link-cli update --check
link-cli graph
```

- `web` starts the web UI, HTTP API, realtime endpoint, and MCP endpoint.
- `validate` validates graph JSON files.
- `seed` creates the default graph node and edge types when the graph is empty.
- `update` checks for or installs newer release binaries.
- `graph` exposes the same graph actions as the MCP server for direct CLI use.

Graph CLI action names match MCP tool names exactly. Use `link-cli graph` to list actions and `link-cli graph <action> --help` for action-specific help:
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ try {
if (command.kind === "web") {
startApp(index);
} else {
const exitCode = runCliCommand(command);
const exitCode = await runCliCommand(command);
process.exit(exitCode ?? 0);
}
37 changes: 36 additions & 1 deletion src/server/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { GraphError } from "../domain/errors";
import { graphToolNames } from "../graph/tools";
import { formatGraphActionHelp, formatGraphHelp, parseCliCommand, parseGraphActionArgs } from "./cli";
import { LINK_VERSION } from "../version";
import { formatGraphActionHelp, formatGraphHelp, formatTopLevelHelp, parseCliCommand, parseGraphActionArgs } from "./cli";

describe("parseCliCommand", () => {
test("defaults to top-level help", () => {
Expand All @@ -12,6 +13,34 @@ describe("parseCliCommand", () => {
expect(parseCliCommand(["bun", "src/index.ts", "web"])).toEqual({ kind: "web" });
expect(parseCliCommand(["bun", "src/index.ts", "validate"])).toEqual({ kind: "validate" });
expect(parseCliCommand(["bun", "src/index.ts", "seed"])).toEqual({ kind: "seed" });
expect(parseCliCommand(["bun", "src/index.ts", "update"])).toEqual({ kind: "update", checkOnly: false, version: undefined });
});

test("parses update options", () => {
expect(parseCliCommand(["bun", "src/index.ts", "update", "--check"])).toEqual({
kind: "update",
checkOnly: true,
version: undefined,
});
expect(parseCliCommand(["bun", "src/index.ts", "update", "--version", "v1.2.3"])).toEqual({
kind: "update",
checkOnly: false,
version: "v1.2.3",
});
expect(parseCliCommand(["bun", "src/index.ts", "update", "--version=1.2.3"])).toEqual({
kind: "update",
checkOnly: false,
version: "1.2.3",
});
});

test("rejects invalid update options", () => {
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "extra"])).toThrow(GraphError);
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "--missing"])).toThrow(GraphError);
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "--version"])).toThrow(GraphError);
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "--version", " "])).toThrow(GraphError);
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "--version= "])).toThrow(GraphError);
expect(() => parseCliCommand(["bun", "src/index.ts", "update", "--check", "--version", "1.2.3"])).toThrow(GraphError);
});

test("rejects legacy flags and web seed", () => {
Expand Down Expand Up @@ -66,6 +95,12 @@ describe("parseGraphActionArgs", () => {
});

describe("graph help", () => {
test("top-level help prints the CLI version and update command", () => {
const help = formatTopLevelHelp();
expect(help).toStartWith(`link-cli ${LINK_VERSION}`);
expect(help).toContain("update");
});

test("lists graph actions from the shared registry", () => {
const help = formatGraphHelp();
for (const name of graphToolNames) {
Expand Down
46 changes: 46 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { GraphError } from "../domain/errors";
import { getGraphTool, graphTools, isGraphToolName, type GraphToolName, type JsonMap } from "../graph/tools";
import { formatLinkVersion } from "../version";

export type CliCommand =
| { kind: "help" }
| { kind: "web" }
| { kind: "validate" }
| { kind: "seed" }
| { kind: "update"; checkOnly: boolean; version?: string }
| { kind: "graph-help" }
| { kind: "graph-action-help"; action: GraphToolName }
| { kind: "graph-action"; action: GraphToolName; args: JsonMap };
Expand Down Expand Up @@ -65,6 +67,43 @@ function readFlagValue(args: string[], index: number, field: string): { value: s
return { value: next, nextIndex: index + 1 };
}

function parseUpdateCommand(args: string[]): Extract<CliCommand, { kind: "update" }> {
let checkOnly = false;
let version: string | undefined;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) usageError(`Unexpected positional argument "${arg}".`);

if (arg === "--check") {
checkOnly = true;
continue;
}

if (arg === "--version") {
const value = args[i + 1];
if (value === undefined || value.startsWith("--")) usageError("--version requires a value.");
version = value.trim();
Comment thread
PabloZaiden marked this conversation as resolved.
if (!version) usageError("--version requires a value.");
i += 1;
continue;
}

if (arg.startsWith("--version=")) {
version = arg.slice("--version=".length).trim();
if (!version) usageError("--version requires a value.");
continue;
}

usageError("Unknown update option.", { option: arg });
}

if (checkOnly && version !== undefined) usageError("Cannot combine --check with --version.");

return { kind: "update", checkOnly, version };
}

export function parseGraphActionArgs(args: string[], allowedFields = allGraphFields): JsonMap {
const parsed: JsonMap = {};

Expand Down Expand Up @@ -125,6 +164,10 @@ export function parseCliCommand(argv: string[]): CliCommand {
return { kind: "seed" };
}

if (command === "update") {
return parseUpdateCommand(rest);
}

if (command === "graph") {
const [action, ...actionArgs] = rest;
if (action === undefined || action === "--help" || action === "-h") return { kind: "graph-help" };
Expand All @@ -140,13 +183,16 @@ export function parseCliCommand(argv: string[]): CliCommand {

export function formatTopLevelHelp(binaryName = "link-cli"): string {
return [
formatLinkVersion(binaryName),
"",
"Usage:",
` ${binaryName} <command>`,
"",
"Commands:",
" web Start the web UI, HTTP API, realtime endpoint, and MCP endpoint.",
" validate Validate graph JSON files.",
" seed Create default graph types when the graph is empty.",
" update Check for or install newer Link release binaries.",
" graph Run graph actions directly from the CLI.",
"",
`Run "${binaryName} graph" to list graph actions.`,
Expand Down
6 changes: 5 additions & 1 deletion src/server/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { GraphError } from "../domain/errors";
import { callGraphTool } from "../graph/tools";
import { JsonGraphRepository, validateGraphPath } from "../storage/json";
import { LINK_VERSION } from "../version";
import { loadConfig } from "./config";
import { runUpdateCommand } from "./update";
import {
formatGraphActionHelp,
formatGraphHelp,
Expand All @@ -18,7 +20,7 @@ function printGraphError(error: GraphError): void {
if (error.details !== undefined) console.error(JSON.stringify(error.details, null, 2));
}

export function runCliCommand(command: CliCommand): number | undefined {
export async function runCliCommand(command: CliCommand): Promise<number | undefined> {
try {
switch (command.kind) {
case "help":
Expand All @@ -30,6 +32,8 @@ export function runCliCommand(command: CliCommand): number | undefined {
return runValidateCommand();
case "seed":
return runSeedCommand();
case "update":
return await runUpdateCommand(command, { currentVersion: LINK_VERSION });
case "graph-help":
console.log(formatGraphHelp());
return 0;
Expand Down
Loading