osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# Writing Plugins

This guide covers how to write an osp plugin, wire it into discovery,
and package it for distribution.

## How plugins work

A plugin is an executable that:

1. Responds to `--describe` with a JSON capability declaration
2. Receives commands as arguments and writes protocol JSON to stdout
3. Is discovered by osp via filesystem search or manifest

The protocol is subprocess-based. Any language works as long as the
binary handles stdin/stdout JSON correctly. For normal execution,
stdout is reserved for protocol payloads. Human diagnostics belong on
stderr.

## Minimal example

A plugin that provides an `echo` command:

```bash
#!/usr/bin/env bash
set -euo pipefail

if [[ "${1:-}" == "--describe" ]]; then
    cat <<'EOF'
{
  "protocol_version": 1,
  "plugin_id": "my-echo",
  "plugin_version": "0.1.0",
  "commands": [
    {
      "name": "echo",
      "about": "Echo back the arguments",
      "args": [
        { "name": "text", "about": "Text to echo", "multi": true }
      ]
    }
  ]
}
EOF
    exit 0
fi

# Skip the command name (first arg is "echo"), echo the rest
shift
TEXT="${*:-hello}"

cat <<EOF
{
  "protocol_version": 1,
  "ok": true,
  "data": [{ "message": "$TEXT" }],
  "error": null,
  "messages": [],
  "meta": {
    "format_hint": null,
    "columns": ["message"],
    "column_align": []
  }
}
EOF
```

Save as `osp-my-echo`, make it executable, and place it in your PATH.
Then:

```bash
osp echo hello world
```

## Best-effort helper scripts

The repository includes two convenience scripts under `scripts/`:

- `scripts/plugin_describe_from_argparse.py`
- `scripts/plugin_describe_from_click.py`

They can inspect a Python `argparse` parser or a Click/Typer command tree
and emit a `DescribeV1`-shaped JSON skeleton.

These scripts are a nice gesture, not a compatibility promise. They are
best-effort helpers for bootstrapping plugin metadata, not something the
project guarantees will stay current with every upstream Python framework
change. If one of them falls behind, feel free to fix it and submit a PR.

## Protocol reference

### Describe (capability declaration)

When invoked with `--describe`, a plugin must print a `DescribeV1` JSON
object to stdout and exit 0. Stdout must contain only that JSON
payload.

```json
{
  "protocol_version": 1,
  "plugin_id": "my-plugin",
  "plugin_version": "0.1.0",
  "min_osp_version": null,
  "commands": [
    {
      "name": "mycommand",
      "about": "Short description",
      "args": [
        {
          "name": "target",
          "about": "What to query",
          "multi": false,
          "suggestions": [
            { "value": "users", "meta": "Query users" },
            { "value": "groups", "meta": "Query groups" }
          ]
        }
      ],
      "flags": {
        "--verbose": {
          "about": "Show extra detail",
          "flag_only": true
        },
        "--limit": {
          "about": "Max results",
          "flag_only": false
        }
      },
      "subcommands": []
    }
  ]
}
```

#### DescribeV1 fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `protocol_version` | integer | yes | Must be `1` |
| `plugin_id` | string | yes | Unique identifier, non-empty |
| `plugin_version` | string | yes | Semantic version |
| `min_osp_version` | string | no | Minimum osp version required |
| `commands` | array | yes | At least one command |

#### Command fields

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `name` | string | required | Top-level command name |
| `about` | string | `""` | Short help text |
| `args` | array | `[]` | Positional arguments |
| `flags` | object | `{}` | Named flags (keys start with `--`) |
| `subcommands` | array | `[]` | Nested subcommands |

#### Argument fields

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `name` | string | `null` | Display name for help |
| `about` | string | `null` | Help text |
| `multi` | bool | `false` | Accepts multiple values |
| `value_type` | string | `null` | `"Path"` for path completion |
| `suggestions` | array | `[]` | Tab completion values |

#### Flag fields

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `about` | string | `null` | Help text |
| `flag_only` | bool | `false` | `true` = boolean flag, `false` = takes a value |
| `multi` | bool | `false` | Can be repeated |
| `value_type` | string | `null` | `"Path"` for path completion |
| `suggestions` | array | `[]` | Tab completion values |

### Execute (command invocation)

For normal command execution, the plugin receives:

- **argv**: `<plugin-exe> <command> [args...]`
  For example: `osp-my-plugin mycommand alice --verbose` arrives as
  `argv = ["osp-my-plugin", "mycommand", "alice", "--verbose"]`
- **stdin**: not used (reserved for future protocol extensions)
- **stdout**: must write exactly one `ResponseV1` JSON object and nothing else
- **stderr**: human diagnostics only

Protocol rules:

- Exit code `0` means `osp` should parse stdout as `ResponseV1`
- Non-zero exit means process-level failure, even if stdout contains JSON
- `ok=false` is still a valid protocol response and must use exit code `0`
- Use the `error` object and optional `messages` for application-level failures
- Reserve non-zero exits for crashes, missing prerequisites, or transport/setup failures

Author checklist:

- `--describe` prints only `DescribeV1` JSON to stdout and exits `0`
- normal execution prints only `ResponseV1` JSON to stdout and exits `0`
- `ok=false` uses the `error` object instead of a non-zero exit
- human diagnostics go to stderr, not stdout
- delegated help may print plain text
- command data lives in `data`; operator-facing commentary lives in `messages`
- `messages[].text` is never empty

For delegated help (`osp <plugin-command> --help` or `help`), `osp` passes
the request through directly. In that mode, the plugin may print plain help
text instead of `ResponseV1`.

#### ResponseV1

```json
{
  "protocol_version": 1,
  "ok": true,
  "data": [
    { "uid": "alice", "cn": "Alice Smith", "mail": "alice@example.com" },
    { "uid": "bob", "cn": "Bob Jones", "mail": "bob@example.com" }
  ],
  "error": null,
  "messages": [
    { "level": "info", "text": "Found 2 results" }
  ],
  "meta": {
    "format_hint": null,
    "columns": ["uid", "cn", "mail"],
    "column_align": []
  }
}
```

#### ResponseV1 fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `protocol_version` | integer | yes | Must be `1` |
| `ok` | bool | yes | `true` = success, `false` = error |
| `data` | any | yes | Array of row objects for table display, or any JSON value |
| `error` | object | if `ok=false` | Error details (see below) |
| `messages` | array | no | Diagnostic messages shown to user |
| `meta` | object | yes | Output metadata |

#### Validation rules

- If `ok` is `true`, `error` must be `null`
- If `ok` is `false`, `error` must be present
- `protocol_version` must be `1`

#### Error object

```json
{
  "code": "NOT_FOUND",
  "message": "User alice not found",
  "details": {}
}
```

| Field | Type | Notes |
|-------|------|-------|
| `code` | string | Machine-readable error code |
| `message` | string | Human-readable error message |
| `details` | any | Optional structured error data |

#### Messages

Messages are shown to the user alongside the output:

| Level | When to use |
|-------|-------------|
| `error` | Something failed |
| `warning` | Something unexpected but not fatal |
| `success` | Positive confirmation |
| `info` | Neutral information |
| `trace` | Debug-level detail (shown with `-v`) |

`osp` renders plugin `messages` on stderr using the same grouping and
verbosity rules as built-in commands. Keep table/JSON/value data in
`data`; keep human commentary in `messages`.

#### Meta fields

| Field | Type | Notes |
|-------|------|-------|
| `format_hint` | string | Suggest output format (`"table"`, `"mreg"`, etc.) |
| `columns` | array | Column order for table display |
| `column_align` | array | Per-column alignment: `"default"`, `"left"`, `"center"`, `"right"` |

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | Success |
| 2 | Usage/argument error |
| 10 | Resource not found |
| 20 | Auth/config error |
| 30 | Upstream API failure |
| 40 | Internal error |

Any non-zero exit is treated as a plugin failure. `osp` does not try to
recover protocol JSON from stdout in that case. The stderr output is
shown to the user.

## Environment variables

osp injects environment variables into the plugin process:

### Runtime hints

| Variable | Values | Notes |
|----------|--------|-------|
| `OSP_UI_VERBOSITY` | `error`, `warning`, `success`, `info`, `trace` | User's verbosity level |
| `OSP_DEBUG_LEVEL` | `0`-`3` | Debug verbosity from `-d` flags |
| `OSP_FORMAT` | `auto`, `json`, `table`, `md`, `mreg`, `value` | Requested output format |
| `OSP_COLOR` | `auto`, `always`, `never` | Color preference |
| `OSP_UNICODE` | `auto`, `always`, `never` | Unicode preference |
| `OSP_TERMINAL_KIND` | `cli`, `repl`, `unknown` | Whether running in CLI or REPL |
| `OSP_PROFILE` | profile name | Active config profile (if set) |
| `OSP_TERMINAL` | terminal name | Raw `TERM` value (if set) |

### Plugin-specific

| Variable | Notes |
|----------|-------|
| `OSP_COMMAND` | The top-level command being executed |

### Config-driven plugin env

Users can pass config values to plugins via the config file:

```toml
[default]
extensions.plugins.env.api_url = "https://api.example.com"

[default]
extensions.plugins.my-plugin.env.token = "secret"
```

These become environment variables:

- `extensions.plugins.env.api_url` becomes `OSP_PLUGIN_CFG_API_URL`
- `extensions.plugins.my-plugin.env.token` becomes
  `OSP_PLUGIN_CFG_TOKEN` (only for `my-plugin`)

Plugin-specific values override shared values for the same key.

## Discovery

osp searches for plugins in this order:

1. `--plugin-dir <dir>` (CLI flag, repeatable)
2. `OSP_PLUGIN_PATH` (colon-separated directories)
3. Bundled plugin directories:
   - `OSP_BUNDLED_PLUGIN_DIR` env var
   - `<osp-binary-dir>/plugins`
   - `<osp-binary-dir>/../lib/osp/plugins`
4. `<platform-config-dir>/osp/plugins/` (for example
   `~/.config/osp/plugins/` on Linux)
5. `PATH` (searches for `osp-*` executables) only when
   `extensions.plugins.discovery.path = true`

The simplest way to make a plugin available: name it `osp-<something>`,
make it executable, and put it in an explicit plugin directory, in
`OSP_PLUGIN_PATH`, or in `PATH` after enabling path discovery.

### Discovery caching

Plugin `--describe` results are cached in
`<platform-cache-dir>/osp/describe-v1.json`, keyed by executable path,
file size, and modification time. On Linux this is typically
`~/.cache/osp/describe-v1.json`, and `XDG_CACHE_HOME` overrides the base
cache dir when set. The cache is invalidated automatically when the
binary changes.

Force a cache refresh:

```bash
osp plugins refresh
```

## Packaging with a manifest

For bundled distribution, plugins use a `manifest.toml` file placed
alongside the executables:

```toml
protocol_version = 1

[[plugin]]
id = "my-plugin"
exe = "osp-my-plugin"
version = "0.1.0"
enabled_by_default = true
checksum_sha256 = "abc123..."
commands = ["mycommand"]
```

### Manifest fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `protocol_version` | integer | yes | Must be `1` |
| `plugin[].id` | string | yes | Unique plugin identifier |
| `plugin[].exe` | string | yes | Executable filename (no path) |
| `plugin[].version` | string | yes | Semantic version |
| `plugin[].enabled_by_default` | bool | no | Default: `false` |
| `plugin[].checksum_sha256` | string | no | SHA-256 of executable |
| `plugin[].commands` | array | yes | List of top-level commands |

### Validation

When a manifest is present:
- If `checksum_sha256` is set, the executable's SHA-256 must match
  before `osp` runs `--describe`
- The executable's `--describe` output must match the manifest's `id`,
  `version`, and command list
- IDs and executable names must be unique within a manifest

### Bundled layout

```
lib/osp/plugins/
  manifest.toml
  osp-my-plugin
  osp-other-plugin
```

## Plugin management

Users manage plugins with built-in commands:

```bash
osp plugins list              # Show discovered plugins
osp plugins commands          # Show plugin-provided commands
osp plugins enable inventory       # Enable one command
osp plugins disable inventory      # Disable one command
osp plugins clear-state inventory  # Remove explicit command state
osp plugins doctor            # Diagnose plugin issues
osp plugins refresh           # Clear discovery cache
```

### Provider conflicts

When multiple plugins provide the same command, osp does not guess. The
command becomes ambiguous until the user either picks a provider for the
current invocation or stores a preferred provider:

```bash
osp inventory host web-01 --plugin-provider inventory-a
osp plugins select-provider inventory inventory-a
osp plugins clear-provider inventory
```

Plugin command routing is stored in the regular scoped config file. For example:

```toml
[profile.default.plugins.inventory]
state = "enabled"
provider = "inventory-a"
```

## Writing a plugin in Rust

For Rust plugins, you can use clap for argument parsing and serde for
JSON serialization. The `osp-core` crate exports the protocol types:

```rust
use osp_core::plugin::{DescribeV1, ResponseV1, ResponseMetaV1};
```

A Rust plugin can use `DescribeV1::from_clap_command()` to generate
the describe output from a clap `Command` definition automatically,
keeping the CLI and protocol in sync.

## Writing a plugin in Python

```python
#!/usr/bin/env python3
import json
import sys
import os

def describe():
    return {
        "protocol_version": 1,
        "plugin_id": "py-example",
        "plugin_version": "0.1.0",
        "commands": [
            {
                "name": "greet",
                "about": "Greet a user",
                "args": [{"name": "name", "about": "Name to greet"}],
            }
        ],
    }

def execute(args):
    name = args[0] if args else "world"
    return {
        "protocol_version": 1,
        "ok": True,
        "data": [{"greeting": f"Hello, {name}!"}],
        "error": None,
        "messages": [],
        "meta": {"format_hint": None, "columns": ["greeting"], "column_align": []},
    }

if __name__ == "__main__":
    if "--describe" in sys.argv:
        json.dump(describe(), sys.stdout)
    else:
        # argv[0] = script, argv[1] = command name, argv[2:] = args
        result = execute(sys.argv[2:])
        json.dump(result, sys.stdout)
```

Save as `osp-py-example`, `chmod +x`, add to PATH.

## Debugging

Run `osp plugins doctor` to diagnose discovery and protocol issues.

Test your plugin manually:

```bash
# Check describe output
./osp-my-plugin --describe | python3 -m json.tool

# Check command output
./osp-my-plugin mycommand alice | python3 -m json.tool
```

Use `osp --debug` to see plugin invocation details (when
instrumentation is enabled).

## Timeout

Plugins have a default process timeout of 10 seconds. If your plugin
needs longer (large queries, slow APIs), consider streaming partial
results or increasing the timeout via configuration.