## Usage
```shell
-node dist/cli.js [global-options] <command> [subcommand] [options]
+evse-cli [global-options] <command> [subcommand] [options]
```
### Global Options
| `--server-url <url>` | WebSocket URL (overrides config host/port/secure) |
| `-h, --help` | Show help |
-### Commands
+### Using hashIds
+
+Most commands accept optional `[hashId...]` arguments to target specific stations. Omitting them applies the command to all stations.
+
+Hash IDs support **prefix matching** — you can use a short prefix instead of the full ID. The CLI resolves it automatically:
+
+```shell
+# Full hash:
+evse-cli station start e9041c294a82a2d6aa194a801c3ba39d6b24d1cb...
+
+# Short prefix (copied from `station list` output):
+evse-cli station start e9041c294a82
+
+# Even shorter (as long as it's unambiguous):
+evse-cli station start e904
+```
+
+If a prefix matches multiple stations, the CLI returns an error.
+
+### Simulator Commands
#### simulator
```shell
-node dist/cli.js simulator state # Get simulator state and statistics
-node dist/cli.js simulator start # Start the simulator
-node dist/cli.js simulator stop # Stop the simulator
+evse-cli simulator state # Get simulator state and statistics
+evse-cli simulator start # Start the simulator
+evse-cli simulator stop # Stop the simulator
```
#### station
```shell
-node dist/cli.js station list # List all charging stations
-node dist/cli.js station start [hashId...] # Start station(s)
-node dist/cli.js station stop [hashId...] # Stop station(s)
-node dist/cli.js station add -t <template> -n <count> # Add stations from template
-node dist/cli.js station delete [hashId...] # Delete station(s)
+evse-cli station list # List all charging stations
+evse-cli station start [hashId...] # Start station(s)
+evse-cli station stop [hashId...] # Stop station(s)
+evse-cli station add -t <template> -n <count> # Add stations from template
+evse-cli station delete [hashId...] # Delete station(s)
```
**`station add` options:**
| `--supervision-url <url>` | No | Override supervision URL |
| `--auto-start` | No | Auto-start added stations |
+**`station delete` options:**
+
+| Option | Required | Description |
+| ----------------- | -------- | ----------------------------- |
+| `--delete-config` | No | Also delete persistent config |
+
#### template
```shell
-node dist/cli.js template list # List available station templates
+evse-cli template list # List available station templates
```
#### connection
```shell
-node dist/cli.js connection open [hashId...] # Open WebSocket connection
-node dist/cli.js connection close [hashId...] # Close WebSocket connection
+evse-cli connection open [hashId...] # Open WebSocket connection to CSMS
+evse-cli connection close [hashId...] # Close WebSocket connection to CSMS
```
#### connector
```shell
-node dist/cli.js connector lock --connector-id <id> [hashId...] # Lock connector
-node dist/cli.js connector unlock --connector-id <id> [hashId...] # Unlock connector
+evse-cli connector lock --connector-id <id> [hashId...] # Lock connector
+evse-cli connector unlock --connector-id <id> [hashId...] # Unlock connector
```
#### atg
```shell
-node dist/cli.js atg start [hashId...] [--connector-ids <ids...>] # Start ATG
-node dist/cli.js atg stop [hashId...] [--connector-ids <ids...>] # Stop ATG
+evse-cli atg start [hashId...] [--connector-ids <ids...>] # Start ATG
+evse-cli atg stop [hashId...] [--connector-ids <ids...>] # Stop ATG
```
#### transaction
```shell
-node dist/cli.js transaction start --connector-id <id> --id-tag <tag> [hashId...]
-node dist/cli.js transaction stop --transaction-id <id> [hashId...]
+evse-cli transaction start --connector-id <id> --id-tag <tag> [hashId...]
+evse-cli transaction stop --transaction-id <id> [hashId...]
```
#### ocpp
Send OCPP commands directly to charging stations:
```shell
-node dist/cli.js ocpp heartbeat [hashId...]
-node dist/cli.js ocpp authorize --id-tag <tag> [hashId...]
-node dist/cli.js ocpp boot-notification [hashId...]
+evse-cli ocpp heartbeat [hashId...]
+evse-cli ocpp authorize --id-tag <tag> [hashId...]
+evse-cli ocpp boot-notification [hashId...]
+evse-cli ocpp status-notification --connector-id <id> --error-code <code> --status <status> [hashId...]
+evse-cli ocpp meter-values --connector-id <id> [hashId...]
+evse-cli ocpp data-transfer --vendor-id <id> [--message-id <id>] [--data <json>] [hashId...]
```
-Available OCPP commands: `authorize`, `boot-notification`, `data-transfer`, `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `heartbeat`, `log-status-notification`, `meter-values`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `status-notification`, `transaction-event`.
+Other OCPP commands (no extra options): `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `log-status-notification`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `transaction-event`.
-#### performance
+#### supervision
```shell
-node dist/cli.js performance stats # Get performance statistics
+evse-cli supervision set-url --supervision-url <url> [hashId...] # Set supervision URL
```
-#### supervision
+#### performance
```shell
-node dist/cli.js supervision set-url --supervision-url <url> [hashId...] # Set supervision URL
+evse-cli performance stats # Get performance statistics
```
-### JSON Output Mode
+### Local Commands
+
+#### skill
-Use `--json` for machine-readable output on stdout:
+Install the embedded [Agent Skills](https://agentskills.io) skill for AI agent integration:
```shell
-node dist/cli.js --json simulator state
-# {"status":"success","state":{...}}
+evse-cli skill show # Print the SKILL.md to stdout
+evse-cli skill install # Install to .agents/skills/evse-simulator/
+evse-cli skill install --global # Install to ~/.agents/skills/evse-simulator/
+evse-cli skill install --force # Overwrite existing installation
```
-Errors are written to stdout as JSON in `--json` mode.
+Works with OpenCode, Claude Code, GitHub Copilot, Cursor, and other compatible agents.
-### Using hashIds
+### Output Modes
-Most station commands accept optional `[hashId...]` variadic arguments. Omitting them applies the command to all stations:
+| Mode | Flag | Description |
+| --------------- | -------- | -------------------------------------- |
+| Human (default) | — | Colored tables, status icons, counters |
+| JSON | `--json` | Structured JSON on stdout |
-```shell
-# All stations:
-node dist/cli.js station start
+Hash IDs are truncated in human mode for readability. Use `--json` for full hash IDs.
-# Specific stations:
-node dist/cli.js station start abc123 def456
-```
+Errors in `--json` mode are written to stdout as structured JSON.
## Exit Codes
import { env } from 'node:process'
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
+const skill = readFileSync('./skills/evse-simulator/SKILL.md', 'utf8')
const isDevelopmentBuild = env.BUILD === 'development'
bundle: true,
define: {
__CLI_VERSION__: JSON.stringify(pkg.version),
+ __EMBEDDED_SKILL__: JSON.stringify(skill),
'process.env.WS_NO_BUFFER_UTIL': JSON.stringify('1'),
'process.env.WS_NO_UTF_8_VALIDATE': JSON.stringify('1'),
},
--- /dev/null
+---
+name: evse-simulator
+description: Control and monitor an OCPP charging station simulator via CLI. Use when users ask to manage charging stations, send OCPP messages, start/stop the simulator or ATG, list stations or templates, or check simulator state.
+license: Apache-2.0
+compatibility: Requires the simulator UI server to be running with WebSocket enabled.
+metadata:
+ author: SAP
+---
+
+# e-Mobility Charging Station Simulator CLI
+
+## Status
+
+!`evse-cli --version 2>/dev/null || echo "Not installed — see ui/cli/README.md"`
+
+## Configuration
+
+The CLI connects to the simulator UI server via WebSocket (SRPC protocol).
+
+Config file location: `${XDG_CONFIG_HOME:-$HOME/.config}/evse-cli/config.json`
+
+```json
+{
+ "uiServer": {
+ "host": "localhost",
+ "port": 8080,
+ "protocol": "ui",
+ "version": "0.0.1",
+ "authentication": {
+ "enabled": true,
+ "type": "protocol-basic-auth",
+ "username": "admin",
+ "password": "admin"
+ }
+ }
+}
+```
+
+Precedence: defaults < config file < `--config <path>` < `--server-url <url>`.
+
+## Global Options
+
+| Option | Description |
+| -------------------- | ---------------------------- |
+| `--json` | Machine-readable JSON output |
+| `--config <path>` | Path to config file |
+| `--server-url <url>` | WebSocket URL override |
+
+## Commands
+
+### Simulator
+
+```shell
+evse-cli simulator state # Get state, version, template statistics
+evse-cli simulator start # Start the simulator
+evse-cli simulator stop # Stop the simulator
+```
+
+### Stations
+
+```shell
+evse-cli station list # List all stations
+evse-cli station start [hashId...] # Start station(s)
+evse-cli station stop [hashId...] # Stop station(s)
+evse-cli station add -t <template> -n <count> # Add stations
+evse-cli station add -t <template> -n 2 --auto-start # Add and auto-start
+evse-cli station add -t <template> -n 1 --supervision-url ws://csms:8180/path
+evse-cli station delete [hashId...] # Delete station(s)
+```
+
+### Templates
+
+```shell
+evse-cli template list # List available station templates
+```
+
+### Connections
+
+```shell
+evse-cli connection open [hashId...] # Open WebSocket to CSMS
+evse-cli connection close [hashId...] # Close WebSocket to CSMS
+```
+
+### Connectors
+
+```shell
+evse-cli connector lock --connector-id <id> [hashId...] # Lock connector
+evse-cli connector unlock --connector-id <id> [hashId...] # Unlock connector
+```
+
+### ATG (Automatic Transaction Generator)
+
+```shell
+evse-cli atg start [hashId...] # Start ATG on all connectors
+evse-cli atg start --connector-ids 1,2 [hashId...] # Start on specific connectors
+evse-cli atg stop [hashId...] # Stop ATG
+```
+
+### Transactions
+
+```shell
+evse-cli transaction start --connector-id <id> --id-tag <tag> [hashId...]
+evse-cli transaction stop --transaction-id <id> [hashId...]
+```
+
+### OCPP Messages
+
+```shell
+evse-cli ocpp heartbeat [hashId...]
+evse-cli ocpp boot-notification [hashId...]
+evse-cli ocpp authorize --id-tag <tag> [hashId...]
+evse-cli ocpp status-notification --connector-id <id> --error-code <code> --status <status> [hashId...]
+evse-cli ocpp meter-values --connector-id <id> [hashId...]
+evse-cli ocpp data-transfer --vendor-id <id> [--message-id <id>] [--data <json>] [hashId...]
+```
+
+Other OCPP commands (no extra options): `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `log-status-notification`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `transaction-event`.
+
+### Supervision
+
+```shell
+evse-cli supervision set-url --supervision-url <url> [hashId...]
+```
+
+### Performance
+
+```shell
+evse-cli performance stats # Get performance statistics
+```
+
+## Output Modes
+
+- **Human** (default): borderless tables, status icons, colored output
+- **JSON** (`--json`): structured JSON on stdout, parseable by scripts
+
+## Exit Codes
+
+| Code | Meaning |
+| ----- | -------------------------------- |
+| `0` | Success |
+| `1` | Error (connection, server, auth) |
+| `130` | Interrupted (Ctrl+C) |
+
+## hashId Convention
+
+Omitting `[hashId...]` applies the command to ALL stations. Pass one or more hash IDs to target specific stations. Get hash IDs from `evse-cli station list` or `evse-cli --json station list`.
+
+## Common Workflows
+
+### Start simulator and check state
+
+```shell
+evse-cli simulator start
+evse-cli simulator state
+```
+
+### Add stations from template and start ATG
+
+```shell
+evse-cli template list
+evse-cli station add -t keba-ocpp2.station-template -n 3 --auto-start
+evse-cli atg start
+```
+
+### Send OCPP heartbeat to all stations
+
+```shell
+evse-cli ocpp heartbeat
+```
+
+### Change supervision URL and reconnect
+
+```shell
+evse-cli station stop <hashId>
+evse-cli supervision set-url --supervision-url ws://new-csms:8180/path <hashId>
+evse-cli station start <hashId>
+```
import { createOcppCommands } from './commands/ocpp.js'
import { createPerformanceCommands } from './commands/performance.js'
import { createSimulatorCommands } from './commands/simulator.js'
+import { createSkillCommands } from './commands/skill.js'
import { createStationCommands } from './commands/station.js'
import { createSupervisionCommands } from './commands/supervision.js'
import { createTemplateCommands } from './commands/template.js'
program.addCommand(createOcppCommands(program))
program.addCommand(createPerformanceCommands(program))
program.addCommand(createSupervisionCommands(program))
+program.addCommand(createSkillCommands())
registerSignalHandlers()
await program.parseAsync(argv)
export interface ExecuteOptions {
config: UIServerConfigurationSection
- formatter: Formatter
+ formatter?: Formatter
payload: RequestPayload
procedureName: ProcedureName
+ silent?: boolean
timeoutMs?: number
}
-export const executeCommand = async (options: ExecuteOptions): Promise<void> => {
- const { config, formatter, payload, procedureName, timeoutMs } = options
+export const executeCommand = async (options: ExecuteOptions): Promise<ResponsePayload> => {
+ const { config, formatter, payload, procedureName, silent, timeoutMs } = options
const client = new WebSocketClient(wsFactory, config, timeoutMs)
const { url } = client
- const isInteractive = process.stderr.isTTY
+ const isInteractive = !silent && process.stderr.isTTY
const spinner = isInteractive
? ora({ stream: process.stderr }).start(`Connecting to ${url}`)
: null
}
const response: ResponsePayload = await client.sendRequest(procedureName, payload, remaining)
spinner?.stop()
- formatter.output(response)
+ formatter?.output(response)
+ return response
} catch (error: unknown) {
spinner?.fail()
throw error
import type { Command } from 'commander'
import process from 'node:process'
-import { type ProcedureName, type RequestPayload, ServerFailureError } from 'ui-common'
+import {
+ ProcedureName,
+ type RequestPayload,
+ ResponseStatus,
+ ServerFailureError,
+ type UIServerConfigurationSection,
+} from 'ui-common'
+import type { StationListPayload } from '../output/renderers.js'
import type { GlobalOptions } from '../types.js'
import { executeCommand } from '../client/lifecycle.js'
import { loadConfig } from '../config/loader.js'
import { createFormatter } from '../output/formatter.js'
-
export const parseInteger = (value: string): number => {
const n = Number.parseInt(value, 10)
if (Number.isNaN(n)) {
return n
}
+// SHA-384 hex hashes are 96 chars. Treat anything >= half-length as a full hash (skip resolution).
+const MIN_FULL_HASH_LENGTH = 48
+
+const resolveShortHashIds = async (
+ hashIds: string[],
+ config: UIServerConfigurationSection
+): Promise<string[]> => {
+ if (hashIds.length === 0) return []
+
+ const allFull = hashIds.every(id => id.length >= MIN_FULL_HASH_LENGTH)
+ if (allFull) return hashIds
+
+ const response = await executeCommand({
+ config,
+ payload: {},
+ procedureName: ProcedureName.LIST_CHARGING_STATIONS,
+ silent: true,
+ })
+
+ if (response.status !== ResponseStatus.SUCCESS || !Array.isArray(response.chargingStations)) {
+ throw new Error(
+ `Failed to list charging stations for hash ID resolution (status: ${response.status})`
+ )
+ }
+
+ const listResponse = response as StationListPayload
+ const allHashIds = listResponse.chargingStations.map(cs => cs.stationInfo.hashId)
+
+ return hashIds.map(input => {
+ if (input.length >= MIN_FULL_HASH_LENGTH) return input
+
+ const matches = allHashIds.filter(full => full.startsWith(input))
+ if (matches.length === 1) return matches[0]
+ if (matches.length === 0) {
+ throw new Error(`No station found matching hash prefix '${input}'`)
+ }
+ throw new Error(
+ `Ambiguous hash prefix '${input}' matches ${matches.length.toString()} stations`
+ )
+ })
+}
+
export const runAction = async (
program: Command,
procedureName: ProcedureName,
const formatter = createFormatter(rootOpts.json)
try {
const config = await loadConfig({ configPath: rootOpts.config, url: rootOpts.serverUrl })
- await executeCommand({ config, formatter, payload, procedureName })
+
+ let resolvedPayload = payload
+ if (Array.isArray(payload.hashIds) && payload.hashIds.length > 0) {
+ resolvedPayload = {
+ ...payload,
+ hashIds: await resolveShortHashIds(payload.hashIds, config),
+ }
+ }
+
+ await executeCommand({ config, formatter, payload: resolvedPayload, procedureName })
process.exitCode = 0
} catch (error: unknown) {
if (error instanceof ServerFailureError) {
--- /dev/null
+import { Command } from 'commander'
+import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { resolve } from 'node:path'
+import process from 'node:process'
+
+declare const __EMBEDDED_SKILL__: string
+
+const SKILL_DIR_NAME = 'evse-simulator'
+const SKILL_FILE_NAME = 'SKILL.md'
+
+const getInstallDir = (global: boolean): string =>
+ global
+ ? resolve(homedir(), '.agents', 'skills', SKILL_DIR_NAME)
+ : resolve(process.cwd(), '.agents', 'skills', SKILL_DIR_NAME)
+
+export const createSkillCommands = (): Command => {
+ const cmd = new Command('skill').description('Show or install the embedded agent skill')
+
+ cmd
+ .command('show')
+ .description('Print the embedded SKILL.md to stdout')
+ .action(() => {
+ process.stdout.write(__EMBEDDED_SKILL__)
+ })
+
+ cmd
+ .command('install')
+ .description('Install the skill into .agents/skills/evse-simulator/')
+ .option('--global', 'Install to ~/.agents/skills/ instead of project-local')
+ .option('-f, --force', 'Overwrite existing installation')
+ .action((options: { force?: boolean; global?: boolean }) => {
+ const dir = getInstallDir(options.global === true)
+ const filepath = resolve(dir, SKILL_FILE_NAME)
+
+ if (existsSync(filepath) && options.force !== true) {
+ process.stderr.write(`Skill already installed at ${filepath}\nUse --force to overwrite.\n`)
+ process.exitCode = 1
+ return
+ }
+
+ try {
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(filepath, __EMBEDDED_SKILL__, 'utf8')
+ } catch (error: unknown) {
+ const msg = error instanceof Error ? error.message : String(error)
+ process.stderr.write(`Failed to install skill: ${msg}\n`)
+ process.exitCode = 1
+ return
+ }
+ process.stdout.write(`Installed skill to ${filepath}\n`)
+ })
+
+ return cmd
+}
-import type { ConnectorEntry, EvseEntry, ResponsePayload } from 'ui-common'
+import type { ChargingStationData, ResponsePayload } from 'ui-common'
import chalk from 'chalk'
import process from 'node:process'
wsIcon,
} from './format.js'
+export type StationListPayload = ResponsePayload & {
+ chargingStations: ChargingStationData[]
+}
+
type PerformancePayload = ResponsePayload & {
performanceStatistics: unknown[]
}
}
}
-type StationPayload = ResponsePayload & {
- chargingStations: {
- connectors?: ConnectorEntry[]
- evses?: EvseEntry[]
- started?: boolean
- stationInfo: {
- chargingStationId: string
- hashId: string
- ocppVersion?: string
- templateName?: string
- }
- supervisionUrl?: string
- timestamp?: number
- wsState?: number
- }[]
-}
-
type TemplatePayload = ResponsePayload & {
templates: string[]
}
)
}
-const isStationList = (p: ResponsePayload): p is StationPayload =>
+const isStationList = (p: ResponsePayload): p is StationListPayload =>
'chargingStations' in p && Array.isArray(p.chargingStations)
const isTemplateList = (p: ResponsePayload): p is TemplatePayload =>
)
}
-const renderStationList = (payload: StationPayload): void => {
+const renderStationList = (payload: StationListPayload): void => {
const stations = payload.chargingStations
if (stations.length === 0) {
process.stdout.write(chalk.dim('No charging stations\n'))
chalk.dim(truncateId(si.hashId)),
`${wsIcon(cs.wsState)} ${chalk.dim(`${available.toString()}/${total.toString()}`)}`,
chalk.dim(si.ocppVersion ?? '–'),
- chalk.dim(si.templateName?.replace('.station-template', '') ?? '–'),
+ chalk.dim(si.templateName.replace('.station-template', '')),
fuzzyTime(cs.timestamp),
])
}
process.stdout.write(`${table.toString()}\n`)
- const started = stations.filter(s => s.started === true).length
+ const started = stations.filter(s => s.started).length
const connected = stations.filter(s => s.wsState === 1).length
process.stdout.write(
chalk.dim(
import process from 'node:process'
import { type ResponsePayload, ResponseStatus } from 'ui-common'
-import { borderlessTable } from './format.js'
+import { borderlessTable, truncateId } from './format.js'
import { tryRenderPayload } from './renderers.js'
export const outputTable = (payload: ResponsePayload): void => {
)
const table = borderlessTable(['Hash ID'])
for (const id of payload.hashIdsSucceeded) {
- table.push([id])
+ table.push([truncateId(id)])
}
process.stdout.write(table.toString() + '\n')
}
if (payload.responsesFailed != null && payload.responsesFailed.length > 0) {
const table = borderlessTable(['Hash ID', 'Error'])
for (const entry of payload.responsesFailed) {
- table.push([entry.hashId ?? '(unknown)', entry.errorMessage ?? 'Unknown error'])
+ table.push([truncateId(entry.hashId ?? '(unknown)'), entry.errorMessage ?? 'Unknown error'])
}
process.stderr.write(table.toString() + '\n')
} else {
const table = borderlessTable(['Hash ID'])
for (const id of payload.hashIdsFailed) {
- table.push([id])
+ table.push([truncateId(id)])
}
process.stderr.write(table.toString() + '\n')
}