@poietic-tech/cli 0.0.1-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Small
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @poietic-tech/cli
2
+
3
+ The `poietic-tech` command: sign in to [auth.poietic.tech](https://auth.poietic.tech),
4
+ manage your agents, and run the commands of any installed poietic module. It is built for
5
+ people and for AI agents: tables for people, JSON for machines, stable exit codes, and it
6
+ never prompts or reads stdin.
7
+
8
+ Node 20 or later, macOS or Linux (it exits 8 on Windows).
9
+
10
+ ```sh
11
+ npm i -g @poietic-tech/cli
12
+ poietic-tech login
13
+ ```
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ | --- | --- |
19
+ | `login [--label <text>] [--no-browser]` | Sign in through the browser. Prints `Open <link> and enter <code>`, then the account it signed in as |
20
+ | `logout` | End this terminal's login |
21
+ | `whoami [--offline]` | Who commands act as and why; `--offline` reads the saved login and makes no request |
22
+ | `agents list` | List your agents |
23
+ | `agents show <agent>` | Show an agent and its tokens |
24
+ | `agents create <name>` | Create an agent |
25
+ | `agents rename <agent> <new>` | Rename an agent |
26
+ | `agents set <agent> --level none\|builder\|delegator` | Set what an agent may do |
27
+ | `agents act-as <agent> on\|off` | Allow or stop acting as an agent |
28
+ | `agents tokens list <agent>` | List an agent's tokens |
29
+ | `agents tokens create <agent> [--label <label>] [--expires never\|90d]` | Create a token; the secret is printed once |
30
+ | `agents tokens revoke <agent> <token-id>` | Revoke a token |
31
+ | `agents delete <agent> --confirm <name>` | Delete an agent; `--confirm` must be its exact current name |
32
+ | `modules list` | Every module found and whether it runs |
33
+ | `modules allow <package>` / `modules disallow <package>` | Allow or stop allowing a package outside `@poietic-tech` |
34
+ | `help [topic]` | Show help; `help exit-codes` prints the exit codes |
35
+
36
+ An `<agent>` is a name or `id:<uuid>`. A name matches only agents you made yourself;
37
+ address an agent a builder made as `id:<uuid>`. When the server asks for a recent sign-in (for example to raise a level, turn act-as on,
38
+ create a token or delete an agent), the command asks you to confirm in the browser and
39
+ retries once; with `--no-wait` it exits 6 instead.
40
+
41
+ Global options, on every command:
42
+
43
+ | Option | Meaning |
44
+ | --- | --- |
45
+ | `--json` | Same as `--format json` |
46
+ | `--format table\|json\|ndjson\|ids` | Output format |
47
+ | `-q`, `--quiet` | Print only what was asked for |
48
+ | `--server <origin>` | The auth server to use. Only `https://auth.poietic.tech` and `http://localhost` or `127.0.0.1` are accepted; any other origin is refused (`untrusted_server`, exit 8) unless `POIETIC_ALLOW_CUSTOM_AUTH=1` |
49
+ | `--no-wait` | Fail with exit 6 instead of waiting for a step-up |
50
+ | `--no-color` | Never colour output |
51
+ | `-V`, `--version` | Print the version |
52
+ | `-h`, `--help` | Show help |
53
+
54
+ ## Output and environment
55
+
56
+ Results go to stdout; messages, links, warnings and errors go to stderr. The format is
57
+ `table` when stdout is a terminal and `json` otherwise; `--format` or `--json` overrides
58
+ it, and so does `POIETIC_TECH_FORMAT`, with the flag winning over the variable. In a
59
+ machine format an error is one JSON object on stderr:
60
+ `{ "error": "<code>", "message": "…", "status"?: number }`. A command named without a
61
+ subcommand (`poietic-tech`, `poietic-tech agents`) prints its help on stdout and exits 0.
62
+
63
+ Ctrl-C stops the request or browser wait in progress and exits 130. A module command that
64
+ does not stop on its own exits 130 at a second Ctrl-C.
65
+
66
+ | Variable | Effect |
67
+ | --- | --- |
68
+ | `POIETIC_TECH_FORMAT` | Default output format (`table`, `json`, `ndjson`, `ids`) |
69
+ | `POIETIC_AUTH_URL` | The auth server, when `--server` is not given; the same origin rule applies |
70
+ | `POIETIC_ALLOW_CUSTOM_AUTH` | Set to `1` to allow an auth origin other than `auth.poietic.tech` or localhost |
71
+ | `POIETIC_IDENTITY` | Act as this agent instead of as yourself. Ignored, with a warning, when `POIETIC_AGENT_TOKEN` is set |
72
+ | `POIETIC_AGENT_TOKEN` | Authenticate as an agent with its `pat_` token instead of a saved login |
73
+ | `NO_COLOR` | Disable colour |
74
+
75
+ `POIETIC_CREDENTIALS_DIR` and `XDG_CONFIG_HOME` move the folder below.
76
+
77
+ Exit codes: run `poietic-tech help exit-codes`.
78
+
79
+ ## Files
80
+
81
+ Everything lives in `~/.config/poietic-tech/` (a folder of mode 0700, files of mode 0600):
82
+
83
+ - `login.json` and `tokens.json`: the saved login and cached tokens, managed by
84
+ `@poietic-tech/auth/node`. The command never reads or writes them itself.
85
+ - `modules.json`: the packages outside `@poietic-tech` that you have allowed
86
+ (`{ "version": 1, "allowed": ["pkg-name", …] }`). It holds no secret, but it decides what
87
+ code runs with your login, so it is protected the same way.
88
+
89
+ ## Writing a module
90
+
91
+ A module is an npm package that adds a subcommand. Install it next to the command or
92
+ globally, and `poietic-tech` finds it at startup by reading its `package.json`; nothing is
93
+ imported until its command is used (`poietic-tech --help` lists it from its `summary`).
94
+
95
+ ```json
96
+ {
97
+ "name": "@poietic-tech/issues",
98
+ "peerDependencies": { "@poietic-tech/cli": "^0.1.0" },
99
+ "poietic-tech": { "command": "issues", "entry": "./dist/poietic-tech.js", "api": 1, "summary": "Issue tracker" }
100
+ }
101
+ ```
102
+
103
+ - `command` matches `^[a-z][a-z0-9-]{0,31}$` and must not be a built-in (`login`, `logout`,
104
+ `whoami`, `agents`, `modules`, `help`, or the help topic `exit-codes`). Two modules claiming
105
+ one command are both refused.
106
+ - `entry` is a path inside the package; one that escapes it, or that cannot be read, is refused.
107
+ - `api` is the contract version the module was written against. This host supports `1`.
108
+ - `summary` is one line for `--help`.
109
+ - The package's `name` must equal its folder name in `node_modules`.
110
+
111
+ The entry's default export is the result of `defineModule`, imported from the only part of
112
+ this package a module may rely on, `@poietic-tech/cli/module`:
113
+
114
+ ```ts
115
+ import { defineModule } from "@poietic-tech/cli/module";
116
+
117
+ export default defineModule((ctx) => {
118
+ ctx.program.command("list").description("List issues").action(async () => {
119
+ const caller = await ctx.caller();
120
+ const res = await caller.fetch("https://issues.poietic.tech/api/issues");
121
+ const rows = (await res.json()) as { key: string; title: string }[];
122
+ ctx.output.list(rows, { columns: [["KEY", (r) => r.key], ["TITLE", (r) => r.title]] });
123
+ });
124
+ });
125
+ ```
126
+
127
+ A module that throws while loading fails only its own command (`module_failed`, exit 1).
128
+
129
+ `ModuleContext`:
130
+
131
+ | Member | What it is |
132
+ | --- | --- |
133
+ | `program` | The module's own commander `Command`, already named and attached; add subcommands to it |
134
+ | `caller()` | Lazily resolves the shared `Caller`; cached for the run, so commands that never call it never read the credential file |
135
+ | `output` | `item(obj, columns?)`, `list(rows, { columns, id? })` (`id` gives the value `--format ids` prints per row), `message(text)` (stderr), `warn(text)` (stderr), `secret(value, warning, extra?)` (stdout once, the warning on stderr; `extra` fields join the JSON form), honouring `--json`, `--format` and `-q` |
136
+ | `fail(code, message, opts?)` | Throws a `CliError` the host renders and maps to an exit code. A code in the exit-code table uses its row; an unknown code maps by `opts.status` (400, 409, 422 to 2; 401 to 3; 403 to 5; 404 to 4; 0, 429 and 5xx to 8), else exits 1 |
137
+ | `stepUp()` | Runs the browser confirmation, honouring `--no-wait` |
138
+ | `server` | The normalised auth origin in use |
139
+ | `env` | The environment the host was given (read-only) |
140
+ | `version` | The host's version and supported API versions |
141
+
142
+ ### What may load
143
+
144
+ Packages in the `@poietic-tech` scope load automatically. Any other package loads only
145
+ after `poietic-tech modules allow <package>`, because a module runs in the same process as
146
+ your login. `modules list` shows each module and why it does or does not run.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,13 @@
1
+ type Env = Readonly<Record<string, string | undefined>>;
2
+ /** An npm package name, optionally scoped. It cannot start with "." or name a parent folder. */
3
+ export declare const PACKAGE_NAME: RegExp;
4
+ /** Where the allow file lives: `<credentials folder>/modules.json`. */
5
+ export declare function allowFilePath(env: Env): string;
6
+ /**
7
+ * The packages outside @poietic-tech whose modules may run. Every entry must be
8
+ * a package name, since each one becomes a folder path under a root.
9
+ */
10
+ export declare function readAllowed(env: Env): Promise<string[]>;
11
+ export declare function allow(env: Env, name: string): Promise<"added" | "already" | "scoped">;
12
+ export declare function disallow(env: Env, name: string): Promise<"removed" | "absent">;
13
+ export {};
@@ -0,0 +1,50 @@
1
+ import { join } from "node:path";
2
+ import { CredentialFileError, credentialsDir, readConfigFile, writeConfigFile } from "@poietic-tech/auth/node";
3
+ import { CliError } from "./errors.js";
4
+ /** An npm package name, optionally scoped. It cannot start with "." or name a parent folder. */
5
+ export const PACKAGE_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
6
+ const SCOPE = "@poietic-tech/";
7
+ /** Where the allow file lives: `<credentials folder>/modules.json`. */
8
+ export function allowFilePath(env) {
9
+ return join(credentialsDir(env), "modules.json");
10
+ }
11
+ function checkName(name) {
12
+ if (typeof name !== "string" || !PACKAGE_NAME.test(name)) {
13
+ throw new CliError("bad_usage", `${JSON.stringify(name)} is not a package name.`);
14
+ }
15
+ }
16
+ /**
17
+ * The packages outside @poietic-tech whose modules may run. Every entry must be
18
+ * a package name, since each one becomes a folder path under a root.
19
+ */
20
+ export async function readAllowed(env) {
21
+ const data = await readConfigFile("modules", { env });
22
+ if (data === null)
23
+ return [];
24
+ const allowed = data.allowed;
25
+ if (!Array.isArray(allowed) || !allowed.every((n) => typeof n === "string" && PACKAGE_NAME.test(n))) {
26
+ throw new CredentialFileError(`${allowFilePath(env)} is not a modules file this tool understands. Delete it and allow the modules again.`);
27
+ }
28
+ return [...new Set(allowed)].sort();
29
+ }
30
+ async function save(env, allowed) {
31
+ await writeConfigFile("modules", { allowed: [...new Set(allowed)].sort() }, { env });
32
+ }
33
+ export async function allow(env, name) {
34
+ checkName(name);
35
+ if (name.startsWith(SCOPE))
36
+ return "scoped";
37
+ const allowed = await readAllowed(env);
38
+ if (allowed.includes(name))
39
+ return "already";
40
+ await save(env, [...allowed, name]);
41
+ return "added";
42
+ }
43
+ export async function disallow(env, name) {
44
+ checkName(name);
45
+ const allowed = await readAllowed(env);
46
+ if (!allowed.includes(name))
47
+ return "absent";
48
+ await save(env, allowed.filter((n) => n !== name));
49
+ return "removed";
50
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ import { onInterrupt } from "./interrupt.js";
4
+ import { processIO } from "./io.js";
5
+ import { run } from "./main.js";
6
+ const controller = new AbortController();
7
+ onInterrupt(process, controller, (code) => process.exit(code));
8
+ process.exitCode = await run(process.argv.slice(2), processIO(controller.signal));
@@ -0,0 +1,20 @@
1
+ import type { Command } from "commander";
2
+ import { type Caller } from "@poietic-tech/auth/node";
3
+ import type { CliIO } from "./io.js";
4
+ import { type ModuleContext, type PoieticTechModule } from "./module.js";
5
+ import { Output, type Globals } from "./output.js";
6
+ export declare const VERSION = "0.1.0";
7
+ /** What the host holds for one run. Built-in modules are factories over it. */
8
+ export interface Host {
9
+ readonly io: CliIO;
10
+ /** Set by the preAction hook, after parse. */
11
+ globals: Globals | null;
12
+ output(): Output;
13
+ caller(): Promise<Caller>;
14
+ /** The built-in command names of this run, set by `run`. */
15
+ builtins: readonly string[];
16
+ }
17
+ export type Builtin = [name: string, summary: string, make: (host: Host) => PoieticTechModule];
18
+ export declare function createHost(io: CliIO): Host;
19
+ /** The context a module's setup receives. Every member is read at call time: globals exist only after parse. */
20
+ export declare function createContext(host: Host, program: Command): ModuleContext;
@@ -0,0 +1,80 @@
1
+ import { resolveCaller, serverOrigin, stepUp } from "@poietic-tech/auth/node";
2
+ import { CliError } from "./errors.js";
3
+ import { MODULE_API } from "./module.js";
4
+ import { Output } from "./output.js";
5
+ export const VERSION = "0.1.0";
6
+ export function createHost(io) {
7
+ let output = null;
8
+ let caller = null;
9
+ const host = {
10
+ io,
11
+ globals: null,
12
+ builtins: [],
13
+ output() {
14
+ if (!host.globals)
15
+ throw new CliError("internal", "Output was used before the options were read.");
16
+ return (output ??= new Output(io, host.globals));
17
+ },
18
+ /** Resolved once per run; its warnings (POIETIC_IDENTITY ignored, say) are printed then, once, for every command. */
19
+ caller() {
20
+ return (caller ??= resolveCaller({
21
+ server: globals(host).server,
22
+ env: io.env,
23
+ now: io.now,
24
+ sleep: io.sleep,
25
+ onNotice: (m) => host.output().message(m),
26
+ }).then((c) => {
27
+ for (const w of c.warnings)
28
+ host.output().warn(w);
29
+ return c;
30
+ }));
31
+ },
32
+ };
33
+ return host;
34
+ }
35
+ function globals(host) {
36
+ if (!host.globals)
37
+ throw new CliError("internal", "The options were read before parse.");
38
+ return host.globals;
39
+ }
40
+ /** The context a module's setup receives. Every member is read at call time: globals exist only after parse. */
41
+ export function createContext(host, program) {
42
+ const { io } = host;
43
+ const output = Object.freeze({
44
+ item: (obj, columns) => host.output().item(obj, columns),
45
+ list: (rows, opts) => host.output().list(rows, opts),
46
+ message: (text) => host.output().message(text),
47
+ warn: (text) => host.output().warn(text),
48
+ secret: (value, warning, extra) => host.output().secret(value, warning, extra),
49
+ });
50
+ const env = Object.freeze({ ...io.env });
51
+ const version = Object.freeze({ host: VERSION, api: Object.freeze([MODULE_API]) });
52
+ return Object.freeze({
53
+ program,
54
+ caller: () => host.caller(),
55
+ output,
56
+ fail(code, message, opts) {
57
+ throw new CliError(code, message, opts);
58
+ },
59
+ async stepUp() {
60
+ const g = globals(host);
61
+ if (!g.wait) {
62
+ throw new CliError("reauth_required", "This needs a recent sign-in. Run the same command without --no-wait to confirm in the browser.");
63
+ }
64
+ await stepUp({
65
+ server: g.server,
66
+ env: io.env,
67
+ now: io.now,
68
+ sleep: io.sleep,
69
+ signal: io.signal,
70
+ onNotice: (m) => host.output().message(m),
71
+ onCode: (p) => host.output().message(`Confirm it's you: open ${p.verificationUri} and enter ${p.userCode}`),
72
+ });
73
+ },
74
+ get server() {
75
+ return serverOrigin({ server: globals(host).server, env: io.env });
76
+ },
77
+ env,
78
+ version,
79
+ });
80
+ }
@@ -0,0 +1,41 @@
1
+ import { clean } from "./text.js";
2
+ import { type PoieticTechModule } from "./module.js";
3
+ /**
4
+ * Finding modules reads package.json files only; nothing is imported until its
5
+ * command runs. This decides what code runs in-process with the person's login.
6
+ */
7
+ export type ModuleState = {
8
+ kind: "available";
9
+ } | {
10
+ kind: "refused";
11
+ reason: string;
12
+ } | {
13
+ kind: "needs";
14
+ version: string | null;
15
+ } | {
16
+ kind: "not-allowed";
17
+ };
18
+ export interface Discovered {
19
+ name: string;
20
+ version: string;
21
+ command: string | null;
22
+ summary: string;
23
+ source: "local" | "global";
24
+ dir: string;
25
+ entry: string | null;
26
+ state: ModuleState;
27
+ }
28
+ export interface Roots {
29
+ local: string | null;
30
+ global: string;
31
+ }
32
+ /** The node_modules the host was loaded from, and the global one. */
33
+ export declare function discoveryRoots(hostDir: string, execPath: string): Roots;
34
+ export { clean };
35
+ export declare function stateText(s: ModuleState): string;
36
+ export declare function discover(roots: Roots, allowed: readonly string[], opts: {
37
+ builtins: readonly string[];
38
+ unscoped?: boolean;
39
+ }): Promise<Discovered[]>;
40
+ /** Import an available module's entry; any failure is module_failed, naming only the package. */
41
+ export declare function importModule(d: Discovered): Promise<PoieticTechModule>;
@@ -0,0 +1,239 @@
1
+ import { clean } from "./text.js";
2
+ import { readdir, readFile, realpath } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { PACKAGE_NAME } from "./allowlist.js";
6
+ import { CliError } from "./errors.js";
7
+ import { MODULE_API } from "./module.js";
8
+ const SCOPE = "@poietic-tech";
9
+ const HOST = "@poietic-tech/cli";
10
+ const COMMAND = /^[a-z][a-z0-9-]{0,31}$/;
11
+ const SUPPORTED_API = [MODULE_API];
12
+ /** The node_modules the host was loaded from, and the global one. */
13
+ export function discoveryRoots(hostDir, execPath) {
14
+ const up = resolve(hostDir, "../..");
15
+ const local = basename(up) === "node_modules" ? up : null;
16
+ const global = local !== null && basename(dirname(local)) === "lib" ? local : resolve(dirname(execPath), "../lib/node_modules");
17
+ return { local, global };
18
+ }
19
+ // C0 controls, DEL, C1 controls, zero-width characters, line and paragraph
20
+ // separators, bidi embeddings, overrides and isolates, and the BOM.
21
+ export { clean };
22
+ export function stateText(s) {
23
+ switch (s.kind) {
24
+ case "available":
25
+ return "loaded on demand";
26
+ case "refused":
27
+ return `refused: ${s.reason}`;
28
+ case "needs":
29
+ return s.version === null ? "needs a newer poietic-tech" : `needs poietic-tech ${s.version} or later`;
30
+ case "not-allowed":
31
+ return "not allowed";
32
+ }
33
+ }
34
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
35
+ /** `child` is strictly inside `parent` (both absolute, already resolved). */
36
+ function inside(child, parent) {
37
+ const rel = relative(parent, child);
38
+ return rel !== "" && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
39
+ }
40
+ async function readJson(file) {
41
+ try {
42
+ const parsed = JSON.parse(await readFile(file, "utf8"));
43
+ return isObject(parsed) ? parsed : null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ async function list(dir) {
50
+ try {
51
+ return (await readdir(dir)).sort();
52
+ }
53
+ catch {
54
+ return [];
55
+ }
56
+ }
57
+ async function realOrNull(path) {
58
+ try {
59
+ return await realpath(path);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** The lower bound of the module's `@poietic-tech/cli` peer range, if it reads as a version. */
66
+ function neededVersion(pkg) {
67
+ const peers = pkg.peerDependencies;
68
+ if (!isObject(peers) || !Object.hasOwn(peers, HOST) || typeof peers[HOST] !== "string")
69
+ return null;
70
+ const m = /^[\^~>=\s]*(\d+(?:\.\d+){0,2})/.exec(peers[HOST]);
71
+ return m ? m[1] : null;
72
+ }
73
+ async function realRoots(roots) {
74
+ const out = [];
75
+ const pairs = [
76
+ [roots.local !== null && resolve(roots.local) !== resolve(roots.global) ? roots.local : null, "local"],
77
+ [roots.global, "global"],
78
+ ];
79
+ for (const [path, source] of pairs) {
80
+ if (path === null)
81
+ continue;
82
+ const real = await realOrNull(path);
83
+ if (real !== null)
84
+ out.push({ path, real, source });
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Where the entry really is; "outside" when it resolves outside the package or
90
+ * the package outside every root; null when it cannot be resolved at all.
91
+ */
92
+ async function checkEntry(dir, entry, roots) {
93
+ const lexical = resolve(dir, entry);
94
+ if (!inside(lexical, resolve(dir)))
95
+ return "outside";
96
+ const realDir = await realOrNull(dir);
97
+ if (realDir === null || !roots.some((r) => inside(realDir, r.real)))
98
+ return "outside";
99
+ // Missing, a dangling symlink or unreadable: nothing checked, so nothing to import.
100
+ const realEntry = await realOrNull(lexical);
101
+ if (realEntry === null)
102
+ return null;
103
+ return inside(realEntry, realDir) ? realEntry : "outside";
104
+ }
105
+ function field(pkg) {
106
+ const f = pkg["poietic-tech"];
107
+ if (!isObject(f))
108
+ return null;
109
+ const { command, entry, api, summary } = f;
110
+ if (typeof command !== "string" || !COMMAND.test(command))
111
+ return null;
112
+ if (typeof entry !== "string" || entry === "")
113
+ return null;
114
+ if (typeof api !== "number" || !Number.isInteger(api))
115
+ return null;
116
+ if (typeof summary !== "string")
117
+ return null;
118
+ return { command, entry, api, summary };
119
+ }
120
+ async function inspect(root, name, allowed, roots, builtins) {
121
+ const dir = join(root.path, name);
122
+ const pkg = await readJson(join(dir, "package.json"));
123
+ if (pkg === null || !Object.hasOwn(pkg, "poietic-tech"))
124
+ return null;
125
+ const raw = isObject(pkg["poietic-tech"]) ? pkg["poietic-tech"] : {};
126
+ const base = {
127
+ name: clean(name),
128
+ version: typeof pkg.version === "string" ? clean(pkg.version) : "",
129
+ command: typeof raw.command === "string" ? clean(raw.command) : null,
130
+ summary: typeof raw.summary === "string" ? clean(raw.summary) : "",
131
+ source: root.source,
132
+ dir,
133
+ };
134
+ const refused = (reason) => ({ ...base, entry: null, state: { kind: "refused", reason } });
135
+ // npm aliases install any package under any folder: the published name proves the scope, not the folder.
136
+ if (pkg.name !== name)
137
+ return refused("package name does not match its folder");
138
+ if (!allowed)
139
+ return { ...base, entry: null, state: { kind: "not-allowed" } };
140
+ const f = field(pkg);
141
+ if (f === null)
142
+ return { ...refused("invalid poietic-tech field"), command: null };
143
+ if (!SUPPORTED_API.includes(f.api))
144
+ return { ...base, entry: null, state: { kind: "needs", version: neededVersion(pkg) } };
145
+ if (builtins.has(f.command))
146
+ return refused(`command "${f.command}" is built in`);
147
+ const entry = await checkEntry(dir, f.entry, roots);
148
+ if (entry === "outside")
149
+ return refused("entry is outside the package");
150
+ // Missing, a dangling symlink or unreadable: refused, so it is never imported.
151
+ if (entry === null)
152
+ return refused("entry cannot be read");
153
+ return { ...base, entry, state: { kind: "available" } };
154
+ }
155
+ /** Package names in a root other than @poietic-tech's: top-level and other scopes. */
156
+ async function otherPackages(root) {
157
+ const names = [];
158
+ for (const entry of await list(root)) {
159
+ if (entry.startsWith("@")) {
160
+ if (entry === SCOPE)
161
+ continue;
162
+ for (const sub of await list(join(root, entry)))
163
+ names.push(`${entry}/${sub}`);
164
+ }
165
+ else {
166
+ names.push(entry);
167
+ }
168
+ }
169
+ return names.filter((n) => PACKAGE_NAME.test(n));
170
+ }
171
+ export async function discover(roots, allowed, opts) {
172
+ const real = await realRoots(roots);
173
+ // `help exit-codes` is a built-in topic, so no module may claim the name either.
174
+ const builtins = new Set([...opts.builtins, "help", "exit-codes"]);
175
+ const allowedSet = new Set(allowed.filter((n) => PACKAGE_NAME.test(n)));
176
+ const found = [];
177
+ const seen = new Set();
178
+ const consider = async (root, name, isAllowed) => {
179
+ if (seen.has(name))
180
+ return;
181
+ const d = await inspect(root, name, isAllowed, real, builtins);
182
+ if (d === null)
183
+ return;
184
+ seen.add(name);
185
+ found.push(d);
186
+ };
187
+ for (const root of real) {
188
+ for (const sub of await list(join(root.path, SCOPE))) {
189
+ const name = `${SCOPE}/${sub}`;
190
+ if (name !== HOST && PACKAGE_NAME.test(name))
191
+ await consider(root, name, true);
192
+ }
193
+ }
194
+ for (const name of [...allowedSet].sort()) {
195
+ if (name.startsWith(`${SCOPE}/`))
196
+ continue;
197
+ for (const root of real)
198
+ await consider(root, name, true);
199
+ }
200
+ if (opts.unscoped) {
201
+ for (const root of real) {
202
+ for (const name of await otherPackages(root.path))
203
+ await consider(root, name, allowedSet.has(name));
204
+ }
205
+ }
206
+ // Two modules claiming one command are both refused.
207
+ const claims = new Map();
208
+ for (const d of found) {
209
+ if (d.state.kind !== "available" || d.command === null)
210
+ continue;
211
+ claims.set(d.command, [...(claims.get(d.command) ?? []), d]);
212
+ }
213
+ for (const [command, ds] of claims) {
214
+ if (ds.length < 2)
215
+ continue;
216
+ for (const d of ds) {
217
+ const other = ds.find((o) => o !== d);
218
+ d.state = { kind: "refused", reason: `command "${command}" is also claimed by ${other.name}` };
219
+ d.entry = null;
220
+ }
221
+ }
222
+ return found;
223
+ }
224
+ /** Import an available module's entry; any failure is module_failed, naming only the package. */
225
+ export async function importModule(d) {
226
+ const failed = () => new CliError("module_failed", `The module ${d.name} failed to load.`);
227
+ if (d.state.kind !== "available" || d.entry === null)
228
+ throw failed();
229
+ let mod;
230
+ try {
231
+ mod = (await import(pathToFileURL(d.entry).href)).default;
232
+ }
233
+ catch {
234
+ throw failed();
235
+ }
236
+ if (!isObject(mod) || mod.api !== MODULE_API || typeof mod.setup !== "function")
237
+ throw failed();
238
+ return mod;
239
+ }
@@ -0,0 +1,23 @@
1
+ /** The exit-code table, verbatim. */
2
+ export declare const EXIT_ROWS: readonly {
3
+ exit: number;
4
+ meaning: string;
5
+ codes: readonly string[];
6
+ }[];
7
+ export declare const EXIT: Readonly<Record<string, number>>;
8
+ export declare function exitCodeOf(code: string, status?: number): number;
9
+ export declare class CliError extends Error {
10
+ readonly code: string;
11
+ readonly exitCode: number;
12
+ readonly status?: number;
13
+ constructor(code: string, message: string, opts?: {
14
+ status?: number;
15
+ });
16
+ }
17
+ /** The code a status stands for when the server named none. */
18
+ export declare function codeByStatus(status: number): string;
19
+ export declare function toCliError(error: unknown, signal: AbortSignal): CliError;
20
+ export declare function redact(text: string): string;
21
+ export declare function renderError(e: CliError, machine: boolean, color: boolean): string;
22
+ /** The error for a failed auth-server answer. The body is read for its code only, never quoted. */
23
+ export declare function apiError(res: Response, what: string): Promise<CliError>;