@josfox/jos 4.0.8 → 5.0.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.
Files changed (59) hide show
  1. package/LICENSE +208 -21
  2. package/PROMISES.md +52 -0
  3. package/README.md +294 -146
  4. package/SECURITY-MCP.md +117 -0
  5. package/SECURITY-SECRETS.md +108 -0
  6. package/STYLE.md +23 -0
  7. package/bin/jos +139 -67
  8. package/bin/jos.cmd +5 -0
  9. package/examples/01-hello.jos +44 -0
  10. package/examples/02-criteria.jos +54 -0
  11. package/examples/03-flow-gate.jos +83 -0
  12. package/examples/04-use-template/README.md +22 -0
  13. package/examples/04-use-template/lib/greeting.jos +56 -0
  14. package/examples/04-use-template/use.jos +64 -0
  15. package/examples/05-sealed/.jos/manifest.jos +53 -0
  16. package/examples/05-sealed/README.md +25 -0
  17. package/examples/06-requires.jos +42 -0
  18. package/examples/07-sandbox.jos +39 -0
  19. package/examples/08-ids-ledger-meter.md +26 -0
  20. package/examples/10-ollama.jos +47 -0
  21. package/examples/11-openai.jos +47 -0
  22. package/examples/12-gemini.jos +47 -0
  23. package/examples/13-anthropic.jos +47 -0
  24. package/examples/14-serve.jos +88 -0
  25. package/examples/README.md +105 -0
  26. package/examples/hello.jos +47 -0
  27. package/package.json +31 -9
  28. package/promises.json +894 -0
  29. package/src/args.js +47 -0
  30. package/src/commands/_frozen/add.README.md +1 -0
  31. package/src/commands/_frozen/get.README.md +1 -0
  32. package/src/commands/_frozen/llm.README.md +1 -0
  33. package/src/commands/_frozen/prompts.README.md +1 -0
  34. package/src/commands/_frozen/repo.README.md +1 -0
  35. package/src/commands/_frozen/serve-explorer.README.md +1 -0
  36. package/src/commands/ids.js +101 -0
  37. package/src/commands/init.js +113 -147
  38. package/src/commands/ledger.js +94 -0
  39. package/src/commands/meter.js +96 -0
  40. package/src/commands/migrate.js +214 -0
  41. package/src/commands/overview.js +118 -0
  42. package/src/commands/run.js +164 -214
  43. package/src/commands/runs.js +102 -0
  44. package/src/commands/secrets.js +695 -115
  45. package/src/commands/start.js +210 -0
  46. package/src/commands/validate.js +153 -165
  47. package/src/commands/verify.js +56 -0
  48. package/src/commands/version.js +23 -0
  49. package/src/intention.js +114 -0
  50. package/src/kernel.js +69 -0
  51. package/src/ledgers.js +57 -0
  52. package/src/mcp-guide.js +138 -0
  53. package/src/mcp.js +734 -0
  54. package/src/serve.js +38 -770
  55. package/src/ui.js +98 -0
  56. package/src/commands/add.js +0 -145
  57. package/src/commands/get.js +0 -245
  58. package/src/commands/repo.js +0 -139
  59. package/src/index.js +0 -0
@@ -0,0 +1,108 @@
1
+ # `jos secrets` — threat model, format and migration
2
+
3
+ Applies to `@josfox/jos` 5.0.0-rc.3 and later (verified again under 5.0.0). Code: `src/commands/secrets.js`. Tests: `test/secrets.test.js` (gate: `npm run test:secrets`, 100 % lines, branches and functions).
4
+
5
+ `jos secrets` keeps credentials for one user on one machine, so a `.jos` run or an agent can use them without anyone typing or pasting them. It is not a team vault, a KMS or a sync service; the F2 vault replaces it.
6
+
7
+ ## What lives where
8
+
9
+ | Path | Content | Mode |
10
+ |---|---|---|
11
+ | `~/.jos/secrets/` | the directory | 0700 |
12
+ | `~/.jos/secrets/.master` | master key: 32 random bytes as 64 hex characters | 0600 |
13
+ | `~/.jos/secrets/vault.json` | `{"<name>": <entry>, ...}`: the names in clear, the values encrypted | 0600 |
14
+ | `~/.jos/secrets/vault.json.bak-<UTC time>-v1` | a copy of the vault before `migrate` (old format) | 0600 |
15
+ | `~/.jos/secrets/keychain.json` | keychain backend only: the names (never the values) | 0600 |
16
+
17
+ Every file is written to a unique temporary name created exclusively (`O_EXCL`, mode 0600), synced, then renamed over the target, so a reader never sees a half-written vault and a planted file or symlink at the temporary name is never followed.
18
+
19
+ ## Threat model: who can read what
20
+
21
+ | Who | Can read | Why |
22
+ |---|---|---|
23
+ | The user, through `jos secrets` | names, fingerprints; values only via `exec` (into a child) or `get --reveal` (to a terminal, after typing the name back) | by design |
24
+ | Another local user (not root) | nothing | directory 0700, files 0600; `jos` refuses to read while any mode is wider, or a file is a symlink or owned by someone else, until `jos secrets fix-perms` (which fixes modes, never ownership or symlinks) |
25
+ | Someone who sees the screen, the scrollback or a terminal recording | the fingerprint | `get` prints `NAME exists · hmac:xxxxxxxx · format` (file backend); the hidden prompt of `set --stdin` does not echo. The fingerprint is keyed with the master key, so it does not let them confirm a guess (next rows) |
26
+ | The process list (`ps`), shell history, CI logs of the command line | the names only | values are never accepted as arguments (`set NAME VALUE` exits 64); `exec` puts them in the child's environment, not in its argv |
27
+ | Someone with a copy of `vault.json` only (backup, sync folder, a disk image without `.master`) | the names, the length of each value | AES-256-GCM without the key |
28
+ | Someone who can write `vault.json` but not read `.master` | cannot forge or alter a v2 value undetected: any change of a byte of `iv`, `tag` or `ct`, or moving an entry to another name, fails authentication (exit 1, never a value). They can delete entries, roll the file back to an older copy, or put back a v1 (old format) entry | GCM tag + name as AAD; v1 entries are unauthenticated (CBC), which is why `list` marks them and `migrate` exists |
29
+ | Root, or any process running as the same user | everything | it can read `.master` and `vault.json`, or the environment of a child of `exec`. **This is not protected.** Use the keychain backend to move the key material into the login keychain; even then, a process of the same user can ask `security` for an item `jos` created |
30
+ | An MCP client of `jos serve --mcp` | nothing of `~/.jos/secrets`: the file tools and the module tools deny the directory and everything in it, however the path is written or reached (symlink, hard link, case). With `--allow-shell`, a client runs programs as you and can read the secrets like any process of the same user (`cat ~/.jos/secrets/.master`) | the server's path jail is not a sandbox for `run_command`, which is why it is off by default; what a client can and cannot reach: `SECURITY-MCP.md` |
31
+ | The child started by `exec --only A,B` | the values of A and B (and nothing else from the vault) | that is its purpose; it can print them, so give it only what it needs |
32
+ | Anyone who sees a fingerprint (file backend), without the master key | cannot test a guess: the fingerprint is `hmac:` + the first 8 hex of `HMAC-SHA256(fpkey, value)`, with `fpkey = HKDF-SHA256(ikm = master key, salt = empty, info = "jos-secrets/v2 fingerprint hmac-sha256", length = 32)`, a label of its own, so it is not the encryption key. Before rc.3 it was `sha256:` + the first 8 hex of the bare sha256 of the value, which let anyone who saw it check guesses for a low-entropy secret offline (finding O-04). It is stable for one value in one vault (same value under two names, or set again: same digits), different between vaults (another master key), and 32 bits of a MAC, so it cannot be inverted to a value | a fingerprint is for recognising a value you stored, not for hiding it. What it still shows: two names of the same vault holding the same value have the same fingerprint, and it changes if the master key changes. Whoever has the master key can compute it (and can decrypt everything anyway). The keychain backend prints none (see below) |
33
+
34
+ What never contains a value: stdout and stderr of `jos` (except `get --reveal` to a terminal), error messages (they name the secret, never its value), exceptions (an unexpected error is reported as `secrets: unexpected error`; `JOS_DEBUG=1` shows the original, which has no value either), argv of any process, files other than the vault and its backup. The secrets command emits no receipt and has no `--json`. The in-memory copies of a value (buffers) are zeroed after use, best effort: JavaScript strings, which `exec` needs for the environment, cannot be zeroed.
35
+
36
+ ## Format v2 (written by every `set`)
37
+
38
+ ```json
39
+ {"NAME": {"v": 2, "alg": "aes-256-gcm", "iv": "<24 hex>", "tag": "<32 hex>", "ct": "<hex>"}}
40
+ ```
41
+
42
+ - Key: `HKDF-SHA256(ikm = master key, salt = empty, info = "jos-secrets/v2 aes-256-gcm", length = 32)`. The old CBC entries use the raw master key; deriving a separate key means no key is ever used with two modes.
43
+ - Nonce: 12 bytes from `crypto.randomBytes` for every write (a new nonce even when the value is the same).
44
+ - AAD: the entry name as UTF-8 bytes. An entry copied or renamed to another name does not decrypt.
45
+ - Tag: 16 bytes. The plaintext is the exact bytes read (UTF-8 from a terminal; any bytes from a pipe, up to 1 MiB).
46
+ - An entry is accepted only with exactly these five fields and these lengths; anything else is `vault is corrupt: entry "NAME"`. A number `v` greater than 2 is reported as a newer format this jos does not read.
47
+ - Format is per entry: a vault can mix v1 and v2 entries while it is not migrated.
48
+
49
+ ## Format v1 (4.x and 5.0.0-rc.1/rc.2, read only)
50
+
51
+ `{"NAME": "<32 hex iv>:<hex ciphertext>"}`, AES-256-CBC with the raw master key and PKCS#7 padding, no authentication. It is still read by `get`, `exec` and `list` and is never rewritten implicitly; `list` marks each such entry `[v1 aes-256-cbc, old format]` and counts them.
52
+
53
+ ## Migration (`jos secrets migrate`)
54
+
55
+ 1. Decrypt every v1 entry. If one does not decrypt, stop: nothing is written, no backup is made.
56
+ 2. Copy the exact bytes of `vault.json` to `vault.json.bak-<UTC time>-v1` (created exclusively, 0600; a second migration in the same second gets `-1`, `-2`, ...), read the copy back and compare it byte for byte.
57
+ 3. Re-encrypt every v1 entry as v2 (v2 entries are kept as they are) and write the new vault atomically.
58
+ 4. Read the vault back from disk, check that it parses, has the same number of entries, that every migrated entry is v2 and decrypts to the same bytes. If anything differs, write the original bytes back and stop with an error naming the backup.
59
+
60
+ The backup holds the old unauthenticated ciphertexts under the same master key: delete it once you have checked the vault (`jos secrets list`, `jos secrets get NAME`).
61
+
62
+ Without `migrate`, no command changes a v1 entry: `set` of another name writes the new entry as v2 and keeps the v1 strings byte for byte.
63
+
64
+ ## Permissions
65
+
66
+ Before reading anything, every action except `fix-perms` checks the directory and every entry in it: the directory and subdirectories must be 0700 or narrower, files 0600 or narrower, all owned by the user, no symlinks (neither the directory nor anything inside). Each problem is printed with its path and mode, and the action exits 1 without reading. `jos secrets fix-perms` sets the modes and refuses (exit 1) for symlinks and foreign owners, which need a human. On Windows no POSIX mode is checked (the directory ACL applies).
67
+
68
+ ## Commands
69
+
70
+ | Command | Behaviour |
71
+ |---|---|
72
+ | `set NAME --stdin` | From a pipe: reads until EOF, removes one final `\n` or `\r\n`, at most 1 MiB. From a terminal: hidden prompt (raw mode, no echo), backspace edits, Enter or Ctrl-D ends, Ctrl-C cancels (exit 130). Empty value: exit 64. A value argument: exit 64 with an explanation. Names: 1–256 characters, no control characters, and valid environment variable names: `[A-Za-z_][A-Za-z0-9_]*` (letters, digits and `_`, not starting with a digit), the same rule as `exec --only`, so a stored secret can always be handed to a command (finding H-02). Anything else exits 64 and says the rule; nothing is created. |
73
+ | `get NAME` | File backend: `NAME exists · hmac:<8 hex> · <format>`; decrypts (so a tampered entry fails here too) but prints no value. The fingerprint is keyed (threat model above). Keychain backend: `NAME exists · macOS keychain`, no fingerprint (below). `get` accepts any name already in the vault, valid or not. |
74
+ | `get NAME --reveal` | Only when stdin and stdout are both terminals, and only after the name is typed back. Otherwise exit 1 and a hint to use `exec`. |
75
+ | `exec --only A,B -- CMD [ARGS]` | Every named secret must exist (or the command is not started); names must be valid environment variable names (the rule of `set`: `[A-Za-z_][A-Za-z0-9_]*`; otherwise exit 64). The child gets the current environment plus those variables, its own stdio, no shell. Exit code: the child's; 127 if it is not found, 126 if it cannot be executed; 128+n if it dies by signal n. SIGINT, SIGTERM and SIGHUP are relayed to the child. |
76
+ | `list` | Names, with the v1 entries marked. Reads no value, needs no decryption. Lists every name, valid or not. |
77
+ | `delete NAME` | Removes the entry. Accepts any name already in the vault, valid or not. |
78
+ | `migrate`, `fix-perms` | Above. `migrate` does not validate or rename: it rewrites the entries under the names they have. |
79
+
80
+ **Vaults with names that `set` would now refuse.** Older versions (4.x, 5.0.0-rc.1/rc.2) accepted any name (`my-key`, `a=b`, `bad name`, `../x`). Such an entry is not lost or rejected: `list`, `get` (fingerprint, and `--reveal` on a terminal) and `delete` keep working on it, `migrate` rewrites it as v2 under the same name, and it is untouched by `set` of other names. What does not work is handing it to a command (`exec --only` needs a valid name) and overwriting it with `set`. To keep the value under a valid name: `get NAME --reveal` on a terminal, `set NEW_NAME --stdin`, then `delete NAME`.
81
+
82
+ The master key is created only by the first `set` into an empty vault. If `vault.json` has entries and `.master` is missing, every command that needs it fails with `master key is missing`: a new key next to old entries would make them undecryptable.
83
+
84
+ ## Keychain backend (macOS, `JOS_SECRETS_BACKEND=keychain`)
85
+
86
+ - Items: generic passwords with service `jos` and account `NAME` in the default (login) keychain.
87
+ - Write: `/usr/bin/security add-generic-password -U -s jos -a NAME -w`, with `-w` last so `security` prompts for the password; `jos` starts it in a new session without a controlling terminal, so the prompt reads the value (twice: value and confirmation) from the stdin pipe. The value is never in an argument. After writing, `jos` reads the item back and compares; if it differs, the item is deleted and the command fails.
88
+ - Values: printable ASCII, at most 128 characters (the limit of `getpass(3)` used by `security`, and `security -w` prints other bytes as hex). Other values exit 64: keep those in the file backend.
89
+ - `keychain.json` keeps the names, because the keychain cannot be listed without dumping it.
90
+ - No fingerprint: `get` prints `NAME exists · macOS keychain`, and `set` does not print one. A keyed fingerprint needs a secret key that `jos` holds and an attacker who only saw the output does not. The file backend has the master key; the keychain backend has none (the keychain is the key store), and a bare digest of the value would bring back O-04 (guesses testable from the output alone). A second key kept just for the fingerprint, in a file next to `keychain.json` or as one more keychain item, would add key material and a write on `get` for a convenience; to check a value use `get NAME --reveal` on a terminal. Names follow the same rule as the file backend (`set` only).
91
+ - `migrate` does not apply (exit 64).
92
+ - Verified against the real keychain by the opt-in test (`JOS_TEST_KEYCHAIN=1 node --test --test-name-pattern="real keychain" test/secrets.test.js`), which creates and deletes its own `JOS_TEST_KC_<random>` item. The default suite uses a stand-in for `security` that records its argv (the tests assert the value is not there), and every test file and gate script deletes `JOS_SECRETS_BACKEND` (and the test files `JOS_TEST_KEYCHAIN`) inherited from the shell before anything runs, so a developer who exports `JOS_SECRETS_BACKEND=keychain` never has the default suite call the real `security` (finding R-07; `test/gate.test.js` checks it statically).
93
+
94
+ ## What this does not protect
95
+
96
+ - A process running as the same user, root, or malware in the user's session (it can read `.master`, the keychain items `jos` created, or the environment of an `exec` child), or a program started by an MCP client of `jos serve --mcp --allow-shell`.
97
+ - Whatever the child of `exec` does with the value.
98
+ - Swap, core dumps or memory inspection while a value is in memory.
99
+ - Deletion, rollback to an older copy, or replacing a v2 entry by a v1 entry by someone who can write the directory (the names and formats are visible with `list`; there is no rollback counter).
100
+ - Two `jos secrets` writers at the same time: each write is atomic, but the last one wins for the whole file (no lock).
101
+ - The fingerprint no longer lets someone without the master key confirm a guess (O-04), but it still shows that two names of one vault hold the same value.
102
+
103
+ ## Tests and coverage
104
+
105
+ `npm run test:secrets` (Node 22+) runs `test/secrets.test.js` with Node's coverage restricted to `src/commands/secrets.js` and fails below 100 % of lines, branches or functions. Every requirement (R1 input, R2 authenticated encryption, R3 migration, R4 permissions, R5 reading, R6 exec, R7 keychain, R8 no value in any output, R0 compatibility) has positive and negative tests. Two lines are excluded with `node:coverage ignore next`, each with its reason in the code:
106
+
107
+ 1. `uid: typeof process.getuid === 'function' ? process.getuid() : -1` — the `-1` side exists only on Windows, where no mode or owner is checked.
108
+ 2. The `'error'` listener on the stdin pipe of `security` — EPIPE when `security` exits before reading; the input (at most 2 × 129 bytes) fits in the pipe buffer before the child can exit, so a test cannot produce it. Without the listener such an error would crash the host.
package/STYLE.md ADDED
@@ -0,0 +1,23 @@
1
+ # Output style of the host
2
+
3
+ Every line `jos` writes for a person looks the same, whichever command wrote it. `npm test` enforces it (`test/style.test.js`).
4
+
5
+ ## Rules
6
+
7
+ 1. **One style module.** `src/ui.js` is the only file that writes to stdout or stderr. A command takes a printer (`ui.printer(stream)`) and uses its parts: `header(title, sub)` (blank line, rule, `TITLE // sub`, rule, blank line), `ok` / `fail` / `warn` / `step` (`✓` green, `✖` red, `⚠` yellow, `▶` cyan, each with an optional gray detail), `hint` (an indented, dimmed follow-up line), `field` (` label: value`), `item` (` • text`), `cmd` (` $ command`), `line` and `text` for a line or block composed with the printer's palette `C`.
8
+ 2. **Color only on a terminal.** A printer's palette is empty when the stream is not a TTY or `NO_COLOR` is set: piped output and logs carry no escape codes. No file other than `src/ui.js` contains an escape code.
9
+ 3. **Machine output is plain.** `--json` goes through `ui.json(stream, value)`: two-space JSON and one newline, never color or decoration, whatever the terminal. Data that is not a message (the output of a step a document ran, a value revealed by `secrets get --reveal`) goes through `ui.data(stream, chunk)`, byte for byte.
10
+ 4. **Errors on stderr, same format.** An error is `✖ jos <command>: <what happened>` on stderr, followed by `hint` lines for the usage or the next step; a warning is `⚠ …`. Results a script reads (`--json`, `version`, `secrets get`, `secrets list`) go to stdout.
11
+ 5. **No raw log line reaches the user.** No `console.log`, `console.error`, `process.stdout.write` or `process.stderr.write` outside `src/ui.js`, and no `<stream>.write` on a stdout, stderr or output stream, except the exceptions below. Debug detail (a stack trace) appears only with `JOS_DEBUG=1`, still through a printer.
12
+ 6. **Marks, not emoji.** `✓ ✖ ⚠ ▶ •` and the rule `━`. The Kitsune banner of `jos --help` is the one picture.
13
+ 7. **Commands to copy are copyable.** Paths in a printed command are absolute and quoted for a POSIX shell when needed (`ui.shellArg`).
14
+
15
+ ## Exceptions
16
+
17
+ `test/style.test.js` reads this table: each row is a file and the exact code it may contain, and the test fails if a row no longer matches a line of its file (an exception that is not used is removed, not kept).
18
+
19
+ <!-- style-exceptions:start -->
20
+ | File | Code | Why |
21
+ |---|---|---|
22
+ | src/mcp.js | `output.write(JSON.stringify(response) + '\n')` | The JSON-RPC 2.0 channel of `jos serve --mcp`: one compact JSON message per line on stdout, read by the MCP client, never by a person. Notices of the server go to stderr through a printer. |
23
+ <!-- style-exceptions:end -->
package/bin/jos CHANGED
@@ -1,28 +1,29 @@
1
1
  #!/usr/bin/env node
2
- const fs = require('fs'), path = require('path'), { spawn } = require('child_process');
3
- const JOS_HOME = path.join(process.env.HOME, '.jos');
2
+ 'use strict';
3
+ const fs = require('fs'), path = require('path'), os = require('os');
4
+ const pkg = require('../package.json');
5
+ const ui = require('../src/ui');
6
+ const { supportsUnicode, RULE } = ui;
4
7
 
5
- // AURORA Design System
6
- const C = {
7
- reset: '\x1b[0m',
8
- bold: '\x1b[1m',
9
- dim: '\x1b[2m',
10
- purple: '\x1b[38;5;135m',
11
- magenta: '\x1b[38;5;198m',
12
- cyan: '\x1b[38;5;51m',
13
- blue: '\x1b[38;5;39m',
14
- white: '\x1b[38;5;255m',
15
- gray: '\x1b[38;5;245m',
16
- pink: '\x1b[38;5;213m',
17
- teal: '\x1b[38;5;44m',
18
- };
8
+ const JOS_HOME = path.join(os.homedir(), '.jos');
9
+
10
+ // A reader that closes the pipe early (`jos run x.jos --json | head -c1`) must end the process quietly with
11
+ // the exit code already decided, not with an unhandled 'error' event and a stack trace.
12
+ for (const stream of [process.stdout, process.stderr]) {
13
+ stream.on('error', (e) => {
14
+ if (e && e.code === 'EPIPE') process.exit(typeof process.exitCode === 'number' ? process.exitCode : 0);
15
+ throw e;
16
+ });
17
+ }
19
18
 
20
- // Aurora palette for random Kitsune colors 🦊
21
- const AURORA_COLORS = [C.purple, C.magenta, C.cyan, C.blue, C.pink, C.teal];
22
- const randomAurora = () => AURORA_COLORS[Math.floor(Math.random() * AURORA_COLORS.length)];
19
+ // Palette for random Kitsune colors
20
+ const randomColor = (C) => {
21
+ const colors = [C.purple, C.magenta, C.cyan, C.blue, C.pink, C.teal];
22
+ return colors[Math.floor(Math.random() * colors.length)];
23
+ };
23
24
 
24
- // The legendary Kitsune fox
25
- const KITSUNE_FOX = `
25
+ // Unicode Kitsune fox (for modern terminals)
26
+ const KITSUNE_UNICODE = `
26
27
  ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀
27
28
  ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⠙⠻⢶⣄⡀⠀⠀⠀⢀⣤⠶⠛⠛⡇⠀⠀⠀
28
29
  ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣇⠀⠀⣙⣿⣦⣤⣴⣿⣁⠀⠀⣸⠇⠀⠀⠀
@@ -39,60 +40,131 @@ const KITSUNE_FOX = `
39
40
  ⠀⠀⠀⠀⠙⠿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠋⠉⠁⠀⠀⠀⠀⠈⠛⠃⠀⠀⠀⠀
40
41
  ⠀⠀⠀⠀⠀⠀⠀⠉⠉⠉⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀`;
41
42
 
42
- const getKitsuneBanner = () => {
43
- const color = randomAurora();
44
- return `${color}${KITSUNE_FOX}${C.reset}
45
- ${C.dim}Made with ❤️ & AI${C.reset}`;
43
+ // ASCII fallback for terminals without Unicode (like Raspberry Pi)
44
+ const KITSUNE_ASCII = `
45
+ /\\___/\\
46
+ ( o o )
47
+ ( =^= )
48
+ ) (
49
+ ( )
50
+ ( KITSUNE )
51
+ ( )
52
+ \\_____/
53
+ JOS // AI Agent`;
54
+
55
+ const getKitsuneBanner = (C) => {
56
+ const color = randomColor(C);
57
+ const fox = supportsUnicode() ? KITSUNE_UNICODE : KITSUNE_ASCII;
58
+ return `${color}${fox}${C.reset}
59
+ ${C.dim}Made with <3 & AI${C.reset}`;
46
60
  };
47
61
 
48
- const ALIASES = { server: 'serve' };
62
+ // The last line of `jos --help` (README, Security: Disclaimer).
63
+ const DISCLAIMER = 'Figures jos prints (cost, tokens, time) are estimates; vendor invoices are authoritative. Any AI output it ' +
64
+ 'relays can be wrong: use the receipts and the ledger as evidence for your own decision. Provided "AS IS", without ' +
65
+ 'warranty (Apache-2.0).';
66
+
67
+ // Fixed command table: no lookup in ~/.jos, no user code is loaded.
68
+ const COMMANDS = {
69
+ run: '../src/commands/run.js',
70
+ validate: '../src/commands/validate.js',
71
+ migrate: '../src/commands/migrate.js',
72
+ verify: '../src/commands/verify.js',
73
+ version: '../src/commands/version.js',
74
+ init: '../src/commands/init.js',
75
+ start: '../src/commands/start.js',
76
+ ids: '../src/commands/ids.js',
77
+ ledger: '../src/commands/ledger.js',
78
+ meter: '../src/commands/meter.js',
79
+ runs: '../src/commands/runs.js',
80
+ secrets: '../src/commands/secrets.js',
81
+ serve: '../src/serve.js',
82
+ };
83
+ const OVERVIEW = '../src/commands/overview.js';
84
+ const NEEDS_HOME = new Set(['serve', 'secrets']);
85
+ const FROZEN = new Set(['llm', 'prompts', 'get', 'repo', 'add']);
86
+ const ALIASES = { server: 'serve', '--version': 'version', '-V': 'version', '-v': 'version', help: '--help' };
49
87
  const rawCmd = process.argv[2];
50
- const cmd = ALIASES[rawCmd] || rawCmd;
88
+ const cmd = Object.prototype.hasOwnProperty.call(ALIASES, rawCmd) ? ALIASES[rawCmd] : rawCmd;
51
89
 
52
- if (!cmd) {
53
- console.log(getKitsuneBanner());
54
- console.log(`\n${C.purple}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}`);
55
- console.log(` ${C.white}${C.bold}JOS${C.reset} ${C.cyan}KERNEL v4.0${C.reset} ${C.gray}// Stoic Architecture${C.reset}`);
56
- console.log(` ${C.dim}Format version v0.0.7 — Specification maturity v0.1.0 (Alpha)${C.reset}`);
57
- console.log(`${C.purple}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C.reset}`);
58
- console.log(`\n${C.white}${C.bold} Usage:${C.reset} ${C.cyan}jos${C.reset} ${C.gray}<command>${C.reset} ${C.dim}[options]${C.reset}\n`);
59
- console.log(`${C.white}${C.bold} Commands:${C.reset}`);
60
- console.log(` ${C.cyan}serve${C.reset} ${C.gray}Start local development server${C.reset}`);
61
- console.log(` ${C.cyan}init${C.reset} ${C.gray}Create new .jos artifact${C.reset}`);
62
- console.log(` ${C.cyan}add${C.reset} ${C.gray}Add task to .jos artifact${C.reset}`);
63
- console.log(` ${C.cyan}validate${C.reset} ${C.gray}Validate .jos artifact${C.reset}`);
64
- console.log(` ${C.purple}run${C.reset} ${C.gray}Execute .jos artifacts${C.reset}`);
65
- console.log(` ${C.magenta}secrets${C.reset} ${C.gray}Manage encrypted credentials${C.reset}`);
66
- console.log(` ${C.blue}get${C.reset} ${C.gray}Fetch packages from repos${C.reset}\n`);
67
- console.log(`${C.dim} Run ${C.cyan}jos <cmd> --help${C.reset}${C.dim} for command-specific options${C.reset}\n`);
68
- process.exit(0);
90
+ function printHelp() {
91
+ const out = ui.printer(process.stdout);
92
+ const { C } = out;
93
+ let kv = 'jos-core not found';
94
+ try { kv = 'jos-core ' + require('../src/kernel').locate().version; } catch (e) { /* reported by version/run */ }
95
+ out.line(getKitsuneBanner(C));
96
+ out.line(`\n${C.purple}${RULE}${C.reset}`);
97
+ out.line(` ${C.white}${C.bold}JOS${C.reset} ${C.cyan}v${pkg.version}${C.reset} ${C.gray}// host of ${kv}${C.reset}`);
98
+ out.line(` ${C.dim}Reads .jos 2.0.0, 1.1.0, 1.0.0 and 0.0.7 documents${C.reset}`);
99
+ out.line(`${C.purple}${RULE}${C.reset}`);
100
+ out.line(`\n${C.white}${C.bold} Usage:${C.reset} ${C.cyan}jos${C.reset} ${C.gray}<command>${C.reset} ${C.dim}[options]${C.reset}\n`);
101
+ out.line(`${C.white}${C.bold} Commands:${C.reset}`);
102
+ out.line(` ${C.cyan}start${C.reset} ${C.gray}Write four examples to run first (no keys, no network)${C.reset}`);
103
+ out.line(` ${C.purple}run${C.reset} ${C.gray}Run a .jos document: intention, receipts, verdict (jos-core)${C.reset}`);
104
+ out.line(` ${C.cyan}validate${C.reset} ${C.gray}Validate a .jos document without running it (jos-core)${C.reset}`);
105
+ out.line(` ${C.cyan}migrate${C.reset} ${C.gray}Reseal a 0.0.7 document sealed by integrity_ref as 2.0 (integrity.hash)${C.reset}`);
106
+ out.line(` ${C.cyan}ids${C.reset} ${C.gray}Content and entity ids: art_, ent_, directory hash${C.reset}`);
107
+ out.line(` ${C.cyan}runs${C.reset} ${C.gray}List the runs in ~/.jos/runs${C.reset}`);
108
+ out.line(` ${C.cyan}ledger${C.reset} ${C.gray}ledger verify <run>: check a run's hash-chained ledger${C.reset}`);
109
+ out.line(` ${C.cyan}meter${C.reset} ${C.gray}Tokens, cost and time of a run, from its verified ledger${C.reset}`);
110
+ out.line(` ${C.cyan}init${C.reset} ${C.gray}Write a new 2.0 atom that run accepts${C.reset}`);
111
+ out.line(` ${C.green}verify${C.reset} ${C.gray}Self-check: run the embedded hello.jos${C.reset}`);
112
+ out.line(` ${C.cyan}version${C.reset} ${C.gray}Print host and kernel versions${C.reset}`);
113
+ out.line(` ${C.magenta}secrets${C.reset} ${C.gray}Manage encrypted credentials${C.reset}`);
114
+ out.line(` ${C.cyan}serve${C.reset} ${C.gray}MCP server (--mcp) for Claude Code, Claude Desktop, Cursor; see jos serve --help${C.reset}\n`);
115
+ out.line(`${C.dim} Frozen in 5.0.0: add, llm, prompts, get, repo (jos <name> says why)${C.reset}`);
116
+ out.line(`${C.dim} Run ${C.cyan}jos <cmd> --help${C.reset}${C.dim} for command-specific options${C.reset}\n`);
117
+ out.line(`${C.dim} ${DISCLAIMER}${C.reset}`);
69
118
  }
70
119
 
71
- // Look for command in multiple locations (priority order)
72
- const PKG_COMMANDS = path.join(__dirname, '..', 'src', 'commands'); // npm package
73
- const USER_COMMANDS = path.join(JOS_HOME, 'commands'); // user installed
74
-
75
- function findCommand(name) {
76
- // 1. Check user's ~/.jos/commands first (allows overrides)
77
- const userPath = path.join(USER_COMMANDS, name, 'index.js');
78
- if (fs.existsSync(userPath)) return userPath;
79
-
80
- // 2. Check npm package src/commands
81
- const pkgPath = path.join(PKG_COMMANDS, `${name}.js`);
82
- if (fs.existsSync(pkgPath)) return pkgPath;
83
-
84
- // 3. Check npm package src/ directly (for serve.js)
85
- const srcPath = path.join(__dirname, '..', 'src', `${name}.js`);
86
- if (fs.existsSync(srcPath)) return srcPath;
120
+ // A first argument that is no command but names a .jos document (a file ending in .jos or .jos.json, or a folder
121
+ // with a .jos/ directory): `jos hola.jos` gets the two commands it most likely meant, and nothing runs by itself.
122
+ function looksLikeDocument(arg) {
123
+ try {
124
+ const st = fs.statSync(arg);
125
+ if (st.isFile()) return /\.jos(\.json)?$/.test(arg);
126
+ return path.basename(path.resolve(arg)) === '.jos' || fs.statSync(path.join(arg, '.jos')).isDirectory();
127
+ } catch (e) { return false; }
128
+ }
87
129
 
88
- return null;
130
+ // `jos --help`: every command. `jos` alone (or ''): what it does and what is in this folder, read only (overview.js).
131
+ if (cmd === '--help' || cmd === '-h') {
132
+ printHelp();
133
+ process.exit(0);
89
134
  }
90
135
 
91
- const cmdPath = findCommand(cmd);
92
- if (!cmdPath) {
93
- const color = randomAurora();
94
- console.log(`\n${color} ✖${C.reset} ${C.white}Unknown command:${C.reset} ${C.cyan}${rawCmd}${C.reset}`);
95
- console.log(`${C.dim} Try: ${C.cyan}jos --help${C.reset}${C.dim} to see available commands${C.reset}\n`);
136
+ if (cmd && !Object.prototype.hasOwnProperty.call(COMMANDS, cmd)) {
137
+ const err = ui.printer(process.stderr);
138
+ err.line();
139
+ if (FROZEN.has(cmd)) {
140
+ err.warn('Command frozen in 5.0.0: ' + rawCmd);
141
+ // The one-line README with the reason ships in the package (package.json "files"); the frozen code does not.
142
+ err.hint('It returns in a later phase. Why: ' + path.join(__dirname, '..', 'src', 'commands', '_frozen', cmd + '.README.md'));
143
+ } else if (looksLikeDocument(rawCmd)) {
144
+ const arg = (rawCmd.startsWith('-') ? '-- ' : '') + ui.shellArg(rawCmd);
145
+ err.fail('Unknown command: ' + rawCmd, '(it is a .jos document; jos does not run it by itself)');
146
+ err.hint('To run it: jos run ' + arg);
147
+ err.hint('To validate it: jos validate ' + arg);
148
+ } else {
149
+ err.fail('Unknown command: ' + rawCmd);
150
+ err.hint('Try: jos --help to see available commands');
151
+ }
152
+ err.line();
96
153
  process.exit(1);
97
154
  }
98
- require(cmdPath).execute(process.argv.slice(3), JOS_HOME);
155
+
156
+ if (NEEDS_HOME.has(cmd)) {
157
+ try { fs.mkdirSync(JOS_HOME, { recursive: true }); } catch (e) { /* the command reports what it cannot do */ }
158
+ }
159
+
160
+ Promise.resolve()
161
+ .then(() => require(cmd ? COMMANDS[cmd] : OVERVIEW).execute(process.argv.slice(3), JOS_HOME))
162
+ .then((code) => { if (typeof code === 'number') process.exitCode = code; })
163
+ .catch((e) => {
164
+ // A host bug, never a document outcome: exit 70 (EX_SOFTWARE). The stack trace only with JOS_DEBUG=1.
165
+ const msg = e && e.message ? e.message : String(e);
166
+ const err = ui.printer(process.stderr);
167
+ err.fail('jos: internal error: ' + msg);
168
+ if (process.env.JOS_DEBUG) err.line(e && e.stack ? e.stack : '');
169
+ process.exitCode = 70;
170
+ });
package/bin/jos.cmd ADDED
@@ -0,0 +1,5 @@
1
+ @echo off
2
+ SETLOCAL
3
+ SET dp0=%~dp0
4
+ node "%dp0%\jos" %*
5
+ ENDLOCAL
@@ -0,0 +1,44 @@
1
+ {
2
+ "jos": {
3
+ "version": "2.0.0",
4
+ "kind": "atom"
5
+ },
6
+ "id": "tour.hello",
7
+ "meta": {
8
+ "name": "tour.hello",
9
+ "version": "1.0.0"
10
+ },
11
+ "intention": {
12
+ "objective": "Print a greeting and exit cleanly",
13
+ "success_criteria": [
14
+ {
15
+ "exit": 0
16
+ },
17
+ {
18
+ "stdout_contains": "hello"
19
+ }
20
+ ]
21
+ },
22
+ "guardrails": {
23
+ "avoid": [
24
+ "rm -rf",
25
+ "curl",
26
+ "wget"
27
+ ],
28
+ "limit": {
29
+ "seconds": 10,
30
+ "output_bytes": 65536,
31
+ "retries": 0
32
+ }
33
+ },
34
+ "execution": {
35
+ "engine": "shell",
36
+ "run": [
37
+ "echo",
38
+ "hello, .jos"
39
+ ],
40
+ "env": [
41
+ "PATH"
42
+ ]
43
+ }
44
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "jos": {
3
+ "version": "2.0.0",
4
+ "kind": "atom"
5
+ },
6
+ "id": "tour.criteria",
7
+ "meta": {
8
+ "name": "tour.criteria",
9
+ "version": "1.0.0"
10
+ },
11
+ "intention": {
12
+ "objective": "Print exactly one greeting line and nothing on stderr",
13
+ "success_criteria": [
14
+ {
15
+ "exit": 0
16
+ },
17
+ {
18
+ "stdout_contains": "hello"
19
+ },
20
+ {
21
+ "stdout_matches": "^hello, \\.jos\\n$"
22
+ },
23
+ {
24
+ "stderr_empty": true
25
+ },
26
+ {
27
+ "not": {
28
+ "exit": 3
29
+ }
30
+ },
31
+ "A person can read the greeting"
32
+ ]
33
+ },
34
+ "guardrails": {
35
+ "avoid": [
36
+ "rm -rf"
37
+ ],
38
+ "limit": {
39
+ "seconds": 10,
40
+ "output_bytes": 65536,
41
+ "retries": 0
42
+ }
43
+ },
44
+ "execution": {
45
+ "engine": "shell",
46
+ "run": [
47
+ "echo",
48
+ "hello, .jos"
49
+ ],
50
+ "env": [
51
+ "PATH"
52
+ ]
53
+ }
54
+ }
@@ -0,0 +1,83 @@
1
+ {
2
+ "jos": {
3
+ "version": "2.0.0",
4
+ "kind": "flow"
5
+ },
6
+ "id": "tour.flow-gate",
7
+ "meta": {
8
+ "name": "tour.flow-gate",
9
+ "version": "1.0.0"
10
+ },
11
+ "intention": {
12
+ "objective": "Build, wait for a person to approve the build, then publish",
13
+ "success_criteria": [
14
+ {
15
+ "exit": 0
16
+ }
17
+ ]
18
+ },
19
+ "guardrails": {
20
+ "avoid": [
21
+ "rm -rf"
22
+ ],
23
+ "limit": {
24
+ "seconds": 10,
25
+ "output_bytes": 65536,
26
+ "retries": 0
27
+ }
28
+ },
29
+ "orchestration": {
30
+ "definitions": {
31
+ "build": {
32
+ "engine": "shell",
33
+ "run": [
34
+ "echo",
35
+ "built"
36
+ ],
37
+ "description": "Make the artifact",
38
+ "intention": {
39
+ "objective": "Make the artifact",
40
+ "success_criteria": [
41
+ {
42
+ "stdout_contains": "built"
43
+ }
44
+ ]
45
+ }
46
+ },
47
+ "publish": {
48
+ "engine": "shell",
49
+ "run": [
50
+ "echo",
51
+ "published"
52
+ ],
53
+ "description": "Runs only after a person approved build",
54
+ "intention": {
55
+ "objective": "Publish the approved build",
56
+ "success_criteria": [
57
+ {
58
+ "stdout_contains": "published"
59
+ }
60
+ ]
61
+ }
62
+ }
63
+ },
64
+ "flows": {
65
+ "main": {
66
+ "steps": [
67
+ "build",
68
+ "publish"
69
+ ],
70
+ "gates": {
71
+ "build": {
72
+ "kind": "human"
73
+ }
74
+ }
75
+ },
76
+ "build-only": {
77
+ "steps": [
78
+ "build"
79
+ ]
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,22 @@
1
+ # 04 · use, a template and a digest
2
+
3
+ `lib/greeting.jos` is a `template`: its slot `name` is declared as a JSON Schema in `execution.inputs`
4
+ (a string of letters and spaces, 1 to 64 characters). `use.jos` is a flow that uses it twice, `ana` and `leo`, each
5
+ with its own `with`, and pins the template's content in `requires[].digest` (its `art_`).
6
+
7
+ Run from the folder that holds `04-use-template/`:
8
+
9
+ | Command | Result |
10
+ |---|---|
11
+ | `jos run 04-use-template/use.jos --library 04-use-template/lib` | `use` resolves `tour.greeting@^1.0.0` in the library folder, the digest matches, both steps run; prints `hello, Leo` (the output of the last step); exit 0 |
12
+ | `jos run 04-use-template/use.jos` | no library given: the pinned artifact is not found, `/requires/0: not satisfied`; nothing runs; exit 5 |
13
+ | `jos run 04-use-template/lib/greeting.jos --with '{"name":"Ana"}'` | the template run directly, instantiated with `--with`; prints `hello, Ana`; exit 0 |
14
+ | `jos run 04-use-template/lib/greeting.jos` | no value for the required slot: `/with: required 'name'`; exit 2 |
15
+ | `jos ids 04-use-template/lib/greeting.jos` | the template's `art_`, the value pinned in `use.jos` |
16
+
17
+ Change anything in `lib/greeting.jos` (for example `hello` to `hi`) and run `use.jos` with the library again: the
18
+ template's `art_` no longer equals the digest, the run is refused (exit 5). Put the text back, or pin the new
19
+ `art_` (`jos ids`), and it runs.
20
+
21
+ The same `use` also resolves from a `.jos/` folder above the document (`.jos/templates/`, `.jos/flows/`, …), nearest
22
+ level first, before the library.
@@ -0,0 +1,56 @@
1
+ {
2
+ "jos": {
3
+ "version": "2.0.0",
4
+ "kind": "template"
5
+ },
6
+ "id": "tour.greeting",
7
+ "meta": {
8
+ "name": "tour.greeting",
9
+ "version": "1.0.0"
10
+ },
11
+ "intention": {
12
+ "objective": "Greet {{ name }} by name",
13
+ "success_criteria": [
14
+ {
15
+ "exit": 0
16
+ },
17
+ {
18
+ "stdout_contains": "{{ name }}"
19
+ }
20
+ ]
21
+ },
22
+ "guardrails": {
23
+ "avoid": [
24
+ "rm -rf"
25
+ ],
26
+ "limit": {
27
+ "seconds": 10,
28
+ "output_bytes": 65536,
29
+ "retries": 0
30
+ }
31
+ },
32
+ "execution": {
33
+ "engine": "shell",
34
+ "inputs": {
35
+ "type": "object",
36
+ "required": [
37
+ "name"
38
+ ],
39
+ "properties": {
40
+ "name": {
41
+ "type": "string",
42
+ "minLength": 1,
43
+ "maxLength": 64,
44
+ "pattern": "^[A-Za-z ]+$"
45
+ }
46
+ }
47
+ },
48
+ "run": [
49
+ "echo",
50
+ "hello, {{ name }}"
51
+ ],
52
+ "env": [
53
+ "PATH"
54
+ ]
55
+ }
56
+ }