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
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ kelpie <url> [options] # compatibility shorthand for `kelpie navigate <ur
| `--tabId <id>`, `--tab-id <id>` | Target a specific tab for macOS commands that support per-tab control |
| `--format <type>` | Output format: `json` (default), `table`, `text` |
| `--timeout <ms>` | CLI-level command timeout for a single device request in milliseconds (default: 10000). Overrides per-method API defaults (typically 5000ms). Not the same as `--scan-timeout` on `kelpie discover`, which controls mDNS scan duration. |
| `--port <port>` | Override default port 8420 |
| `--port <port>` | Override default port 8420. With `--device <ip>`, targets the matching `<ip>:<port>` device before any stale same-IP entry. |
| `--help` | Show help for any command |
| `--version` | Show CLI version |
| `--llm-help` | Show detailed LLM-oriented help with schemas and examples |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@unlikeotherai/kelpie",
"version": "0.1.9",
"version": "0.1.10",
"license": "Apache-2.0",
"type": "module",
"bin": {
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export function getGlobals(program: Command): GlobalOptions {
return program.opts<GlobalOptions>();
}

export function explicitGlobalPort(program: Command, globals: GlobalOptions): number | undefined {
if (program.getOptionValueSource("port") !== "cli") {
return undefined;
}
const rawPort = globals.port as unknown;
const port = typeof rawPort === "number" ? rawPort : Number.parseInt(String(rawPort), 10);
return Number.isInteger(port) && port > 0 ? port : undefined;
}

export function withGlobalTabId(
globals: GlobalOptions,
body?: Record<string, unknown>,
Expand All @@ -20,7 +29,9 @@ export function withGlobalTabId(
export async function requireDevice(program: Command): Promise<DiscoveredDevice | null> {
const globals = getGlobals(program);
if (globals.device) {
const device = await getDevice(globals.device);
const device = await getDevice(globals.device, {
port: explicitGlobalPort(program, globals),
});
if (!device) {
print({ success: false, error: { code: "DEVICE_NOT_FOUND", message: `No device matching "${globals.device}"` } }, globals.format);
process.exitCode = 4;
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/commands/ping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getDevice, getAllDevices } from "../discovery/registry.js";
import { sendCommand } from "../client/http-client.js";
import { print } from "../output/formatter.js";
import type { GlobalOptions, DiscoveredDevice } from "../types.js";
import { explicitGlobalPort } from "./helpers.js";

export function registerPing(program: Command): void {
program
Expand All @@ -13,7 +14,9 @@ export function registerPing(program: Command): void {
const timeout = globals.timeout;

if (globals.device) {
const device = await getDevice(globals.device);
const device = await getDevice(globals.device, {
port: explicitGlobalPort(program, globals),
});
if (!device) {
print(
{ success: false, error: { code: "DEVICE_NOT_FOUND", message: `No device matching "${globals.device}"` } },
Expand Down
54 changes: 45 additions & 9 deletions packages/cli/src/discovery/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,14 @@ export function getAllDevices(): DiscoveredDevice[] {
return Array.from(devices.values());
}

export async function getDevice(query: string): Promise<DiscoveredDevice | undefined> {
export interface DeviceLookupOptions {
port?: number;
}

export async function getDevice(
query: string,
options: DeviceLookupOptions = {},
): Promise<DiscoveredDevice | undefined> {
evictExpired();
// Auto-scan on first use so --device works without a prior `discover` call
if (!autoScanned && devices.size === 0) {
Expand All @@ -72,6 +79,12 @@ export async function getDevice(query: string): Promise<DiscoveredDevice | undef

const all = getAllDevices();
const lowerQuery = query.toLowerCase();
const addressQuery = parseAddressQuery(query, options.port);

if (addressQuery) {
const byAddress = all.find((d) => d.ip === addressQuery.host && d.port === addressQuery.port);
if (byAddress) return byAddress;
}

const byNameExact = all.find((d) => d.name.toLowerCase() === lowerQuery);
if (byNameExact) return byNameExact;
Expand All @@ -81,10 +94,12 @@ export async function getDevice(query: string): Promise<DiscoveredDevice | undef
);
if (byNameFuzzy) return byNameFuzzy;

const byIp = all.find((d) => d.ip === query);
if (byIp) return byIp;
if (!addressQuery?.explicitPort) {
const byIp = all.find((d) => d.ip === query);
if (byIp) return byIp;
}

const directAddress = getDirectAddressDevice(query);
const directAddress = getDirectAddressDevice(addressQuery);
if (directAddress) return directAddress;

return undefined;
Expand Down Expand Up @@ -112,16 +127,37 @@ async function getBrowserAliasDevice(query: string): Promise<DiscoveredDevice |
};
}

function getDirectAddressDevice(query: string): DiscoveredDevice | undefined {
interface AddressQuery {
host: string;
port: number;
explicitPort: boolean;
label: string;
}

function parseAddressQuery(query: string, preferredPort?: number): AddressQuery | undefined {
const match = /^(\d{1,3}(?:\.\d{1,3}){3})(?::(\d+))?$/.exec(query);
if (!match) {
return undefined;
}
const explicitPort = match[2] !== undefined || preferredPort !== undefined;
const port = match[2] ? Number(match[2]) : (preferredPort ?? DEFAULT_PORT);
return {
id: `direct:${query}`,
name: query,
ip: match[1],
port: match[2] ? Number(match[2]) : DEFAULT_PORT,
host: match[1],
port,
explicitPort,
label: match[2] ? query : `${match[1]}:${port}`,
};
}

function getDirectAddressDevice(address: AddressQuery | undefined): DiscoveredDevice | undefined {
if (!address) {
return undefined;
}
return {
id: `direct:${address.label}`,
name: address.label,
ip: address.host,
port: address.port,
platform: "linux",
model: "Kelpie Direct",
width: 0,
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/tests/commands/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,28 @@ describe("CLI program compatibility", () => {

expect(capturedBody()).toEqual({ mode: "readable", tabId: "tab-123" });
});

it("uses --port when resolving a device by IP", async () => {
mockFetch({ success: true, url: "https://example.com/app", title: "Example", loadTime: 10 });
addDevice(device);
addDevice({
...device,
id: "admin-device",
port: 8422,
});
vi.spyOn(console, "log").mockImplementation(() => undefined);

await makeProgram().parseAsync([
"node",
"kelpie",
"--device",
"192.168.1.42",
"--port",
"8422",
"navigate",
"https://example.com/app",
]);

expect(capturedUrl()).toBe("http://192.168.1.42:8422/v1/navigate");
});
});
22 changes: 22 additions & 0 deletions packages/cli/tests/discovery/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,28 @@ describe("device registry", () => {
expect((await getDevice("192.168.1.42"))?.id).toBe("test-uuid-1234");
});

it("prefers an explicit host port over stale same-IP entries", async () => {
addDevices([
makeDevice({ id: "old", ip: "192.168.1.42", port: 8420 }),
makeDevice({ id: "admin", ip: "192.168.1.42", port: 8422 }),
]);

expect((await getDevice("192.168.1.42:8422"))?.id).toBe("admin");
expect((await getDevice("192.168.1.42", { port: 8422 }))?.id).toBe("admin");
});

it("uses the requested direct port when no discovered address matches", async () => {
addDevice(makeDevice({ id: "old", ip: "192.168.1.42", port: 8420 }));

const device = await getDevice("192.168.1.42", { port: 8422 });

expect(device).toMatchObject({
id: "direct:192.168.1.42:8422",
ip: "192.168.1.42",
port: 8422,
});
});

it("returns undefined for unknown device", async () => {
expect(await getDevice("nonexistent")).toBeUndefined();
});
Expand Down
Loading