@homespunapps/cli 1.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.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/argv.js +140 -0
- package/dist/commands/agent.js +62 -0
- package/dist/commands/apps.js +368 -0
- package/dist/commands/attachment-delete.js +37 -0
- package/dist/commands/attachment-download.js +53 -0
- package/dist/commands/attachment-list.js +50 -0
- package/dist/commands/attachment-show.js +37 -0
- package/dist/commands/attachment-token.js +133 -0
- package/dist/commands/attachment-upload.js +65 -0
- package/dist/commands/attachment.js +133 -0
- package/dist/commands/claim.js +68 -0
- package/dist/commands/config.js +262 -0
- package/dist/commands/data.js +168 -0
- package/dist/commands/deploy.js +136 -0
- package/dist/commands/feedback.js +133 -0
- package/dist/commands/key.js +82 -0
- package/dist/commands/logout.js +59 -0
- package/dist/commands/members.js +143 -0
- package/dist/commands/register.js +131 -0
- package/dist/commands/set-key.js +92 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/taste.js +165 -0
- package/dist/config.js +165 -0
- package/dist/format.js +133 -0
- package/dist/index.js +230 -0
- package/dist/input.js +42 -0
- package/dist/output.js +77 -0
- package/dist/resolve-app.js +51 -0
- package/dist/store.js +205 -0
- package/dist/upgrade.js +115 -0
- package/dist/version.js +11 -0
- package/package.json +53 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `homespun skill` — fetch the relay's SKILL.md, or just its version.
|
|
2
|
+
//
|
|
3
|
+
// The relay serves its skill at GET /skills/homespun/SKILL.md and its version
|
|
4
|
+
// at GET /skills/homespun/SKILL.md/version (see
|
|
5
|
+
// packages/relay/src/http/routes/skill.ts). The skill is auto-updating:
|
|
6
|
+
// the relay's deployed image owns both the body and the version, so the
|
|
7
|
+
// agent always reads what the relay it's actually talking to wants it
|
|
8
|
+
// to read.
|
|
9
|
+
//
|
|
10
|
+
// Two verbs:
|
|
11
|
+
// `homespun skill show` — print the full markdown to stdout (the
|
|
12
|
+
// install / refresh path; pipe to a file).
|
|
13
|
+
// `homespun skill version` — print just the relay's skill version (the
|
|
14
|
+
// "is my local copy stale?" probe). The agent
|
|
15
|
+
// compares this to the `<!-- homespun skill v… -->`
|
|
16
|
+
// comment in its local skill file and re-runs
|
|
17
|
+
// `homespun skill show > <path>` when they differ.
|
|
18
|
+
//
|
|
19
|
+
// Both are unauthenticated — the skill route is public on the relay and
|
|
20
|
+
// an agent on a too-old CLI must be able to read the upgrade instructions
|
|
21
|
+
// even before it has registered (or before its key was minted).
|
|
22
|
+
import { assertKnownFlags } from "../argv.js";
|
|
23
|
+
import { resolveRelayUrl } from "../config.js";
|
|
24
|
+
import { fail } from "../output.js";
|
|
25
|
+
const NO_FLAGS = [];
|
|
26
|
+
const NO_BOOLS = [];
|
|
27
|
+
const VERSION_BOOLS = ["plain"];
|
|
28
|
+
import { VERSION } from "../version.js";
|
|
29
|
+
export const skillHelp = `homespun skill — fetch the relay's SKILL.md (or its version)
|
|
30
|
+
|
|
31
|
+
Usage:
|
|
32
|
+
homespun skill show Print the full skill to stdout.
|
|
33
|
+
homespun skill version [--plain] Print just the relay's skill version.
|
|
34
|
+
|
|
35
|
+
The skill is auto-updating: the relay's deployed image owns the version,
|
|
36
|
+
so this is always the skill that matches the relay you are talking to.
|
|
37
|
+
|
|
38
|
+
Unauthenticated — no API key needed. An agent can call either form
|
|
39
|
+
before 'homespun agent register' to bootstrap or refresh its local skill copy.
|
|
40
|
+
|
|
41
|
+
Verbs:
|
|
42
|
+
show Fetch GET /skills/homespun/SKILL.md and write the raw
|
|
43
|
+
markdown to stdout. Pipe to your local skill path:
|
|
44
|
+
homespun skill show > ~/.claude/skills/homespun/SKILL.md
|
|
45
|
+
version Fetch GET /skills/homespun/SKILL.md/version and print
|
|
46
|
+
the relay's skill version. Default output is the
|
|
47
|
+
JSON envelope; --plain prints just the version
|
|
48
|
+
string so an agent can compare it inline in shell.
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--plain (with 'version' only) print the bare version
|
|
52
|
+
string on stdout, no JSON envelope. Useful inside
|
|
53
|
+
a shell pipeline: \`if [ "$(homespun skill version
|
|
54
|
+
--plain)" != "$LOCAL" ]; then ...\`.
|
|
55
|
+
--url <url> Relay base URL (overrides HOMESPUN_URL).
|
|
56
|
+
-h, --help Show this help.
|
|
57
|
+
|
|
58
|
+
Output (stdout):
|
|
59
|
+
(bare) Raw markdown, as served by the relay.
|
|
60
|
+
version { "version": "1.0.0" } — or '1.0.0\\n' with --plain.
|
|
61
|
+
|
|
62
|
+
Errors (stderr): { "error": { "code", "message" } } and non-zero exit.`;
|
|
63
|
+
// Shared fetch with the consistent x-homespun-cli-version header (the skill
|
|
64
|
+
// routes are exempt from the version-skew middleware, but sending it lets
|
|
65
|
+
// access logs see which CLI versions are reading the skill).
|
|
66
|
+
async function fetchOrFail(url) {
|
|
67
|
+
try {
|
|
68
|
+
return await fetch(url, {
|
|
69
|
+
headers: { "x-homespun-cli-version": VERSION },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
74
|
+
fail(`could not reach ${url}: ${msg}`, "fetch_error");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function failOnNon2xx(res, target) {
|
|
78
|
+
if (res.ok)
|
|
79
|
+
return;
|
|
80
|
+
// 404 if the operator stripped the route, 5xx on a static-read failure.
|
|
81
|
+
// Surface the body inline — it may carry a useful message.
|
|
82
|
+
const body = await res.text().catch(() => "");
|
|
83
|
+
fail(`relay returned ${res.status} for ${target}${body ? ": " + body.slice(0, 200) : ""}`, "relay_error");
|
|
84
|
+
}
|
|
85
|
+
// `homespun skill show` — print the full skill.
|
|
86
|
+
async function runSkillFetch(args) {
|
|
87
|
+
assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun skill show");
|
|
88
|
+
const url = resolveRelayUrl(args);
|
|
89
|
+
const target = url + "/skills/homespun/SKILL.md";
|
|
90
|
+
const res = await fetchOrFail(target);
|
|
91
|
+
await failOnNon2xx(res, target);
|
|
92
|
+
const text = await res.text();
|
|
93
|
+
process.stdout.write(text);
|
|
94
|
+
// Ensure the markdown ends with a newline so a pipe-reader (cat | xargs |
|
|
95
|
+
// claude) sees a clean line-terminated boundary even if the relay served
|
|
96
|
+
// a file without a trailing newline.
|
|
97
|
+
if (!text.endsWith("\n"))
|
|
98
|
+
process.stdout.write("\n");
|
|
99
|
+
}
|
|
100
|
+
// `homespun skill version [--plain]` — print just the version.
|
|
101
|
+
async function runSkillVersion(args) {
|
|
102
|
+
assertKnownFlags(args, NO_FLAGS, VERSION_BOOLS, "homespun skill version");
|
|
103
|
+
const url = resolveRelayUrl(args);
|
|
104
|
+
const target = url + "/skills/homespun/SKILL.md/version";
|
|
105
|
+
const res = await fetchOrFail(target);
|
|
106
|
+
await failOnNon2xx(res, target);
|
|
107
|
+
// The relay returns { version: "x.y.z" }. We tolerate a missing/
|
|
108
|
+
// malformed body so a misbehaving relay can't crash this probe — fall
|
|
109
|
+
// through to "0.0.0" the same way the relay does when its own SKILL.md
|
|
110
|
+
// lacks a version comment.
|
|
111
|
+
let body;
|
|
112
|
+
try {
|
|
113
|
+
body = await res.json();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
body = null;
|
|
117
|
+
}
|
|
118
|
+
const version = body !== null &&
|
|
119
|
+
typeof body === "object" &&
|
|
120
|
+
typeof body.version === "string"
|
|
121
|
+
? body.version
|
|
122
|
+
: "0.0.0";
|
|
123
|
+
if (args.bools.has("plain")) {
|
|
124
|
+
process.stdout.write(version + "\n");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
process.stdout.write(JSON.stringify({ version }) + "\n");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function runSkill(args) {
|
|
131
|
+
const sub = args.positionals[0];
|
|
132
|
+
switch (sub) {
|
|
133
|
+
case "show":
|
|
134
|
+
await runSkillFetch(args);
|
|
135
|
+
break;
|
|
136
|
+
case "version":
|
|
137
|
+
await runSkillVersion(args);
|
|
138
|
+
break;
|
|
139
|
+
case undefined:
|
|
140
|
+
fail("missing verb — usage: homespun skill <show|version> (run 'homespun skill --help')", "invalid_args");
|
|
141
|
+
break;
|
|
142
|
+
default:
|
|
143
|
+
fail(`unknown skill verb '${sub}' — expected show|version (run 'homespun skill --help')`, "invalid_args");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// `homespun taste` — read / write / clear the calling agent's freeform "taste
|
|
2
|
+
// notes" markdown attachment.
|
|
3
|
+
//
|
|
4
|
+
// Taste notes are presentation preferences the agent has learned from human
|
|
5
|
+
// feedback ("denser layout", "no rounded corners", "use a dark header") — the
|
|
6
|
+
// kind of guidance that should outlive a single app. The intended loop:
|
|
7
|
+
//
|
|
8
|
+
// 1. Before generating an app template, run `homespun taste get` and feed the
|
|
9
|
+
// `taste` field into the prompt so prior preferences shape the output.
|
|
10
|
+
// 2. When the human gives new presentation feedback, run `homespun taste get`,
|
|
11
|
+
// merge the feedback into the existing notes IN THE PROMPT, then call
|
|
12
|
+
// `homespun taste set` with the WHOLE new attachment (the relay does whole-attachment
|
|
13
|
+
// replace, not append — that's deliberate, so the notes can't grow
|
|
14
|
+
// unbounded into noise).
|
|
15
|
+
//
|
|
16
|
+
// Keep taste notes about *presentation/UI taste only* — colours, density,
|
|
17
|
+
// component preferences. Project context, todos, and per-app state belong
|
|
18
|
+
// somewhere else. Today the attachment is keyed by the agent's API key (per-agent);
|
|
19
|
+
// when app gains first-class humans, this may move to per-human.
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { assertKnownFlags } from "../argv.js";
|
|
22
|
+
import { makeClient } from "../config.js";
|
|
23
|
+
import { printJson, fail, failFromError } from "../output.js";
|
|
24
|
+
const NO_FLAGS = [];
|
|
25
|
+
const NO_BOOLS = [];
|
|
26
|
+
const SET_FLAGS = ["file"];
|
|
27
|
+
const CLEAR_BOOLS = ["yes"];
|
|
28
|
+
export const tasteHelp = `homespun taste — read / write / clear YOUR agent's UI taste notes
|
|
29
|
+
|
|
30
|
+
Taste notes are a small markdown attachment storing presentation preferences your
|
|
31
|
+
agent has picked up from human feedback ("denser table", "no rounded corners",
|
|
32
|
+
"use a dark header"). Read them before generating an app template so prior
|
|
33
|
+
feedback shapes the output; rewrite them whenever the human gives new
|
|
34
|
+
presentation feedback. Keep entries about UI/presentation taste only — not
|
|
35
|
+
project context, todos, or homespun state.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
homespun taste <subcommand> [options]
|
|
39
|
+
|
|
40
|
+
Subcommands:
|
|
41
|
+
get Print the current notes attachment:
|
|
42
|
+
{ taste: string|null, updated_at: string|null, bytes: number }.
|
|
43
|
+
taste is null and bytes is 0 when notes have never been written.
|
|
44
|
+
|
|
45
|
+
set Whole-attachment replace. Source the markdown via --file <path>,
|
|
46
|
+
--file - (read stdin), or by piping into 'homespun taste set' with
|
|
47
|
+
no flag. The relay rejects empty/whitespace-only payloads and
|
|
48
|
+
caps the attachment at MAX_TASTE_BYTES (utf8). To clear the notes,
|
|
49
|
+
use 'homespun taste clear', not 'set' with an empty body.
|
|
50
|
+
|
|
51
|
+
clear Delete the notes. Requires --yes (it is destructive). Prints
|
|
52
|
+
{ cleared: true }.
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
--file <path|-> Source for 'set' — a file path, or '-' to read stdin
|
|
56
|
+
explicitly. Omit to fall back to piped stdin.
|
|
57
|
+
--yes Confirm 'clear'.
|
|
58
|
+
--url <url> Relay base URL (overrides HOMESPUN_URL).
|
|
59
|
+
--api-key <key> Agent API key (overrides HOMESPUN_API_KEY).
|
|
60
|
+
-h, --help Show this help.
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
homespun taste get
|
|
64
|
+
homespun taste set --file ./taste.md
|
|
65
|
+
homespun taste set --file - # explicit stdin
|
|
66
|
+
echo "- denser layout" | homespun taste set
|
|
67
|
+
homespun taste clear --yes
|
|
68
|
+
|
|
69
|
+
Output: stdout is machine-readable JSON.`;
|
|
70
|
+
// Drain process.stdin to a utf8 string. The caller is responsible for
|
|
71
|
+
// deciding that stdin should be read (e.g. an explicit `--file -`, or a
|
|
72
|
+
// non-TTY stdin where data is actually piped). In a TTY this would block
|
|
73
|
+
// waiting for ^D, so the caller MUST gate on `process.stdin.isTTY` first.
|
|
74
|
+
async function readStdin() {
|
|
75
|
+
const chunks = [];
|
|
76
|
+
for await (const chunk of process.stdin) {
|
|
77
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
78
|
+
}
|
|
79
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
80
|
+
}
|
|
81
|
+
async function runTasteGet(args) {
|
|
82
|
+
assertKnownFlags(args, NO_FLAGS, NO_BOOLS, "homespun taste get");
|
|
83
|
+
const client = makeClient(args);
|
|
84
|
+
try {
|
|
85
|
+
const info = await client.getTaste();
|
|
86
|
+
printJson(info);
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
failFromError(e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function runTasteSet(args) {
|
|
93
|
+
assertKnownFlags(args, SET_FLAGS, NO_BOOLS, "homespun taste set");
|
|
94
|
+
const filePath = args.flags.get("file");
|
|
95
|
+
// Source the attachment deterministically — no isTTY-flag fusing, because
|
|
96
|
+
// `!process.stdin.isTTY` is true under every non-interactive caller
|
|
97
|
+
// (pipes, redirects, closed fd, CI, agent harnesses) and would wrongly
|
|
98
|
+
// reject `--file` for the entire target audience. See issue #148.
|
|
99
|
+
//
|
|
100
|
+
// --file - → explicit stdin sentinel
|
|
101
|
+
// --file <path> → read that path (works in TTY and non-TTY alike)
|
|
102
|
+
// (no --file) → fall back to stdin IF non-TTY; error in a TTY
|
|
103
|
+
let taste;
|
|
104
|
+
if (filePath === "-") {
|
|
105
|
+
taste = await readStdin();
|
|
106
|
+
}
|
|
107
|
+
else if (filePath !== undefined) {
|
|
108
|
+
try {
|
|
109
|
+
taste = readFileSync(filePath, "utf8");
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
fail(`failed to read --file '${filePath}': ${e instanceof Error ? e.message : String(e)}`, "invalid_args");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else if (!process.stdin.isTTY) {
|
|
116
|
+
taste = await readStdin();
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
fail("'homespun taste set' needs input — pass --file <path>, pipe markdown on stdin, or use --file -", "invalid_args");
|
|
120
|
+
}
|
|
121
|
+
if (taste.trim().length === 0) {
|
|
122
|
+
fail("'homespun taste set' refuses an empty or whitespace-only attachment — use 'homespun taste clear --yes' to delete the notes", "invalid_args");
|
|
123
|
+
}
|
|
124
|
+
const client = makeClient(args);
|
|
125
|
+
try {
|
|
126
|
+
const info = await client.setTaste(taste);
|
|
127
|
+
printJson(info);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
failFromError(e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function runTasteClear(args) {
|
|
134
|
+
assertKnownFlags(args, NO_FLAGS, CLEAR_BOOLS, "homespun taste clear");
|
|
135
|
+
if (!args.bools.has("yes")) {
|
|
136
|
+
fail("'homespun taste clear' deletes YOUR agent's taste notes — it is destructive. Pass --yes to confirm.", "confirmation_required");
|
|
137
|
+
}
|
|
138
|
+
const client = makeClient(args);
|
|
139
|
+
try {
|
|
140
|
+
await client.clearTaste();
|
|
141
|
+
printJson({ cleared: true });
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
failFromError(e);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
export async function runTaste(args) {
|
|
148
|
+
const sub = args.positionals[0];
|
|
149
|
+
switch (sub) {
|
|
150
|
+
case "get":
|
|
151
|
+
await runTasteGet(args);
|
|
152
|
+
break;
|
|
153
|
+
case "set":
|
|
154
|
+
await runTasteSet(args);
|
|
155
|
+
break;
|
|
156
|
+
case "clear":
|
|
157
|
+
await runTasteClear(args);
|
|
158
|
+
break;
|
|
159
|
+
case undefined:
|
|
160
|
+
fail("missing subcommand — usage: homespun taste <get|set|clear> (run 'homespun taste --help')", "invalid_args");
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
fail(`unknown taste subcommand '${sub}' — expected get|set|clear (run 'homespun taste --help')`, "invalid_args");
|
|
164
|
+
}
|
|
165
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Relay connection config: HOMESPUN_URL / HOMESPUN_API_KEY from the environment,
|
|
2
|
+
// overridable per-invocation with --url / --api-key. Profile selection via
|
|
3
|
+
// --profile / HOMESPUN_PROFILE picks WHICH (url, api_key) pair to load from
|
|
4
|
+
// the saved store. See store.ts for the on-disk layout.
|
|
5
|
+
import { HomespunClient } from "@homespunapps/core";
|
|
6
|
+
import { fail } from "./output.js";
|
|
7
|
+
import { readStore, resolveProfile, storePath } from "./store.js";
|
|
8
|
+
import { VERSION } from "./version.js";
|
|
9
|
+
/**
|
|
10
|
+
* The hosted Homespun relay. Used as the relay-URL fallback so a fresh user only
|
|
11
|
+
* needs an API key — `homespun agent register` against the hosted relay, then go. A
|
|
12
|
+
* self-hoster overrides it with `--url` / `HOMESPUN_URL` / `homespun agent register --url`.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_RELAY_URL = "https://homespun.dev";
|
|
15
|
+
/**
|
|
16
|
+
* Pick the profile-selector source — explicit flag wins over env, env wins
|
|
17
|
+
* over the store's `current_profile`. Returns both the selector value and
|
|
18
|
+
* where it came from so `describeConfig` can report it.
|
|
19
|
+
*/
|
|
20
|
+
function pickProfileSelector(args) {
|
|
21
|
+
const flag = args.flags.get("profile");
|
|
22
|
+
if (flag !== undefined && flag !== "") {
|
|
23
|
+
return { selector: flag, source: "flag" };
|
|
24
|
+
}
|
|
25
|
+
const env = process.env.HOMESPUN_PROFILE;
|
|
26
|
+
if (env !== undefined && env !== "") {
|
|
27
|
+
return { selector: env, source: "env" };
|
|
28
|
+
}
|
|
29
|
+
return { selector: undefined, source: "none" };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve url + apiKey and report the SOURCE of each, WITHOUT making a network
|
|
33
|
+
* call and WITHOUT failing on a missing value (unlike `resolveConfig`). The
|
|
34
|
+
* full API key is never returned — only a short, masked prefix.
|
|
35
|
+
*
|
|
36
|
+
* Resolution model:
|
|
37
|
+
* - `--url` / `HOMESPUN_URL` and `--api-key` / `HOMESPUN_API_KEY` are DIRECT values:
|
|
38
|
+
* they override everything, including any active profile. CI scripts that
|
|
39
|
+
* set those env vars never need to think about profiles.
|
|
40
|
+
* - Otherwise the profile selector (`--profile` flag → `HOMESPUN_PROFILE` env →
|
|
41
|
+
* store's `current_profile`) picks one profile out of the store; the
|
|
42
|
+
* selected profile's `url` and `api_key` are used.
|
|
43
|
+
* - Final fallback for URL is `DEFAULT_RELAY_URL`.
|
|
44
|
+
*/
|
|
45
|
+
export function describeConfig(args) {
|
|
46
|
+
const store = readStore();
|
|
47
|
+
const { selector, source: selectorSource } = pickProfileSelector(args);
|
|
48
|
+
// The store gets visited only if --profile flag is set (explicit
|
|
49
|
+
// selector) and the store has a matching profile, OR the store has a
|
|
50
|
+
// current_profile and no explicit selector overrides it. `resolveProfile`
|
|
51
|
+
// throws on a typo'd selector; we swallow that here so describeConfig
|
|
52
|
+
// can't crash a `homespun config show` — resolveConfig() is the one that
|
|
53
|
+
// surfaces the error when the caller actually needs a key.
|
|
54
|
+
let active;
|
|
55
|
+
try {
|
|
56
|
+
active = resolveProfile(store, selector);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
active = null;
|
|
60
|
+
}
|
|
61
|
+
// URL precedence: --url flag > HOMESPUN_URL env > active profile's url.
|
|
62
|
+
// The default URL is shown only when nothing else is set.
|
|
63
|
+
let url = null;
|
|
64
|
+
let urlSource = "none";
|
|
65
|
+
if (args.flags.get("url")) {
|
|
66
|
+
url = args.flags.get("url");
|
|
67
|
+
urlSource = "flag";
|
|
68
|
+
}
|
|
69
|
+
else if (process.env.HOMESPUN_URL) {
|
|
70
|
+
url = process.env.HOMESPUN_URL;
|
|
71
|
+
urlSource = "env";
|
|
72
|
+
}
|
|
73
|
+
else if (active && active.profile.url) {
|
|
74
|
+
url = active.profile.url;
|
|
75
|
+
urlSource = "profile";
|
|
76
|
+
}
|
|
77
|
+
// API key precedence: --api-key flag > HOMESPUN_API_KEY env > active profile's api_key.
|
|
78
|
+
let apiKey = null;
|
|
79
|
+
let keySource = "none";
|
|
80
|
+
if (args.flags.get("api-key")) {
|
|
81
|
+
apiKey = args.flags.get("api-key");
|
|
82
|
+
keySource = "flag";
|
|
83
|
+
}
|
|
84
|
+
else if (process.env.HOMESPUN_API_KEY) {
|
|
85
|
+
apiKey = process.env.HOMESPUN_API_KEY;
|
|
86
|
+
keySource = "env";
|
|
87
|
+
}
|
|
88
|
+
else if (active && active.profile.apiKey) {
|
|
89
|
+
apiKey = active.profile.apiKey;
|
|
90
|
+
keySource = "profile";
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
url: url ? url.replace(/\/$/, "") : null,
|
|
94
|
+
url_source: urlSource,
|
|
95
|
+
key_prefix: apiKey ? apiKey.slice(0, 10) + "…" : null,
|
|
96
|
+
key_source: keySource,
|
|
97
|
+
profile: active ? active.name : null,
|
|
98
|
+
profile_source: selectorSource,
|
|
99
|
+
config_path: storePath(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve relay URL + API key. Precedence (highest first):
|
|
104
|
+
* url: --url flag → HOMESPUN_URL env → active profile's url → DEFAULT_RELAY_URL
|
|
105
|
+
* apiKey: --api-key → HOMESPUN_API_KEY → active profile's api_key
|
|
106
|
+
* "Active profile" is chosen by `--profile` / `HOMESPUN_PROFILE` / the store's
|
|
107
|
+
* `current_profile`. A typo'd `--profile dev` fails fast with `config_error`
|
|
108
|
+
* — we never silently fall back to a different relay.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveConfig(args) {
|
|
111
|
+
const store = readStore();
|
|
112
|
+
const { selector } = pickProfileSelector(args);
|
|
113
|
+
let active;
|
|
114
|
+
try {
|
|
115
|
+
active = resolveProfile(store, selector);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
fail(e instanceof Error ? e.message : String(e), "config_error");
|
|
119
|
+
}
|
|
120
|
+
const url = args.flags.get("url") ??
|
|
121
|
+
process.env.HOMESPUN_URL ??
|
|
122
|
+
active?.profile.url ??
|
|
123
|
+
DEFAULT_RELAY_URL;
|
|
124
|
+
const apiKey = args.flags.get("api-key") ??
|
|
125
|
+
process.env.HOMESPUN_API_KEY ??
|
|
126
|
+
active?.profile.apiKey ??
|
|
127
|
+
"";
|
|
128
|
+
if (!apiKey) {
|
|
129
|
+
fail("missing API key: set HOMESPUN_API_KEY, pass --api-key <key>, or run 'homespun agent register'", "config_error");
|
|
130
|
+
}
|
|
131
|
+
return { url: url.replace(/\/$/, ""), apiKey };
|
|
132
|
+
}
|
|
133
|
+
/** Build a HomespunClient from resolved config. */
|
|
134
|
+
export function makeClient(args) {
|
|
135
|
+
const cfg = resolveConfig(args);
|
|
136
|
+
return new HomespunClient({
|
|
137
|
+
url: cfg.url,
|
|
138
|
+
apiKey: cfg.apiKey,
|
|
139
|
+
// Sent as `x-homespun-cli-version` on every relay request so the relay can
|
|
140
|
+
// return 426 cli_upgrade_required when this CLI is too old. Single
|
|
141
|
+
// source: ./version.ts.
|
|
142
|
+
cliVersion: VERSION,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve just the relay URL — same precedence as `resolveConfig` but
|
|
147
|
+
* without insisting on an API key. For commands that hit unauthenticated
|
|
148
|
+
* relay routes (e.g. `homespun skill` → GET /skills/homespun/SKILL.md).
|
|
149
|
+
*/
|
|
150
|
+
export function resolveRelayUrl(args) {
|
|
151
|
+
const store = readStore();
|
|
152
|
+
const { selector } = pickProfileSelector(args);
|
|
153
|
+
let active;
|
|
154
|
+
try {
|
|
155
|
+
active = resolveProfile(store, selector);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
fail(e instanceof Error ? e.message : String(e), "config_error");
|
|
159
|
+
}
|
|
160
|
+
const url = args.flags.get("url") ??
|
|
161
|
+
process.env.HOMESPUN_URL ??
|
|
162
|
+
active?.profile.url ??
|
|
163
|
+
DEFAULT_RELAY_URL;
|
|
164
|
+
return url.replace(/\/$/, "");
|
|
165
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Human-readable formatter for `homespun create` output.
|
|
2
|
+
//
|
|
3
|
+
// The CLI is JSON-first — that's how agents call it. But humans run it too:
|
|
4
|
+
// the agent dev iterating on a template, the operator smoke-testing a relay,
|
|
5
|
+
// the developer who fires `homespun create` once a day to grab a URL and hand
|
|
6
|
+
// it to themselves on their phone. Dumping `{ app_id, urls, tokens, ... }`
|
|
7
|
+
// at them is a downgrade in every case where the next step is "open the
|
|
8
|
+
// URL in a browser".
|
|
9
|
+
//
|
|
10
|
+
// In a TTY (and without `--json` on the CLI), this module renders:
|
|
11
|
+
// - the title prominently
|
|
12
|
+
// - each human URL on its own line, copy-friendly
|
|
13
|
+
// - a QR code for the first human URL, scannable from a phone
|
|
14
|
+
// - the expiry as a countdown ("in 1h 0m") + ISO timestamp
|
|
15
|
+
// - the agent stream URL on a dim line (less important for humans)
|
|
16
|
+
//
|
|
17
|
+
// Trust boundary: every interpolated value is a server response or a string
|
|
18
|
+
// the caller asked us to render. No HTML escaping needed — terminal output.
|
|
19
|
+
// We DO neutralise stray ANSI escape characters (a malicious title could
|
|
20
|
+
// otherwise inject colour codes); see stripAnsi.
|
|
21
|
+
import qrcode from "qrcode-terminal";
|
|
22
|
+
/** ANSI helpers. Only applied when writing to a TTY; harmless characters
|
|
23
|
+
* otherwise. We deliberately don't pull in a colour library — the CLI has
|
|
24
|
+
* one runtime dep today (qrcode-terminal) and we'd like to keep the
|
|
25
|
+
* app area tight. */
|
|
26
|
+
const ANSI = {
|
|
27
|
+
reset: "\x1b[0m",
|
|
28
|
+
bold: "\x1b[1m",
|
|
29
|
+
dim: "\x1b[2m",
|
|
30
|
+
cyan: "\x1b[36m",
|
|
31
|
+
green: "\x1b[32m",
|
|
32
|
+
yellow: "\x1b[33m",
|
|
33
|
+
};
|
|
34
|
+
// Strip control chars from interpolated strings. Defends against a relay
|
|
35
|
+
// response (or, more realistically, an echoed title in some future field)
|
|
36
|
+
// carrying ANSI escapes that would otherwise change the user's terminal
|
|
37
|
+
// colour after our output ends.
|
|
38
|
+
// eslint-disable-next-line no-control-regex
|
|
39
|
+
const CTRL_RX = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
40
|
+
function safe(s) {
|
|
41
|
+
return s.replace(CTRL_RX, "");
|
|
42
|
+
}
|
|
43
|
+
/** Generate the QR-code string for `text` using qrcode-terminal's `small`
|
|
44
|
+
* rendering (one terminal char per QR module, ~half the height of the
|
|
45
|
+
* default). Returns the multi-line string, ready to write to stdout. */
|
|
46
|
+
function qrToString(text) {
|
|
47
|
+
let out = "";
|
|
48
|
+
qrcode.generate(text, { small: true }, (s) => {
|
|
49
|
+
out = s;
|
|
50
|
+
});
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Human-friendly countdown from now to `iso`. Returns "in 1h 0m" /
|
|
54
|
+
* "in 45m" / "in 30s" / "expired". Stable enough to test as a string. */
|
|
55
|
+
export function humanCountdown(iso, nowMs = Date.now()) {
|
|
56
|
+
const delta = new Date(iso).getTime() - nowMs;
|
|
57
|
+
if (!Number.isFinite(delta) || delta <= 0)
|
|
58
|
+
return "expired";
|
|
59
|
+
const totalSec = Math.floor(delta / 1000);
|
|
60
|
+
const days = Math.floor(totalSec / 86400);
|
|
61
|
+
const hours = Math.floor((totalSec % 86400) / 3600);
|
|
62
|
+
const mins = Math.floor((totalSec % 3600) / 60);
|
|
63
|
+
const secs = totalSec % 60;
|
|
64
|
+
if (days > 0)
|
|
65
|
+
return `in ${days}d ${hours}h`;
|
|
66
|
+
if (hours > 0)
|
|
67
|
+
return `in ${hours}h ${mins}m`;
|
|
68
|
+
if (mins > 0)
|
|
69
|
+
return `in ${mins}m`;
|
|
70
|
+
return `in ${secs}s`;
|
|
71
|
+
}
|
|
72
|
+
/** Render the homespun-created response for a human reader. Returns the
|
|
73
|
+
* full multi-line string; the caller writes it to stdout. */
|
|
74
|
+
export function formatAppCreated(res, opts = {}) {
|
|
75
|
+
const c = opts.color ?? false;
|
|
76
|
+
const b = c ? ANSI.bold : "";
|
|
77
|
+
const d = c ? ANSI.dim : "";
|
|
78
|
+
const cy = c ? ANSI.cyan : "";
|
|
79
|
+
const g = c ? ANSI.green : "";
|
|
80
|
+
const r = c ? ANSI.reset : "";
|
|
81
|
+
const title = safe(res.title);
|
|
82
|
+
const appId = safe(res.app_id);
|
|
83
|
+
const expiresIn = humanCountdown(res.expires_at);
|
|
84
|
+
const expiresAt = safe(res.expires_at);
|
|
85
|
+
const humanUrls = res.urls.humans.map(safe);
|
|
86
|
+
const agentStream = safe(res.urls.agent_stream);
|
|
87
|
+
const lines = [];
|
|
88
|
+
// Header — "App created" vs. "Existing app reused" if `created`
|
|
89
|
+
// is explicitly false. Dedup hits from #262 carry created=false and the
|
|
90
|
+
// human shouldn't think they made a fresh row.
|
|
91
|
+
const headline = res.created === false
|
|
92
|
+
? `${b}${cy}Existing app reused${r}`
|
|
93
|
+
: `${b}${g}App created${r}`;
|
|
94
|
+
lines.push(headline);
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push(` ${d}Title:${r} ${title}`);
|
|
97
|
+
lines.push(` ${d}App:${r} ${appId}`);
|
|
98
|
+
lines.push(` ${d}Expires:${r} ${expiresIn} ${d}(${expiresAt})${r}`);
|
|
99
|
+
if (res.context_key) {
|
|
100
|
+
lines.push(` ${d}Key:${r} ${safe(res.context_key)}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
if (humanUrls.length === 0) {
|
|
104
|
+
// Dedup-on-existing-app path doesn't re-mint human URLs. Note
|
|
105
|
+
// the situation explicitly rather than rendering a blank section.
|
|
106
|
+
lines.push(`${d}No human URLs minted on this response — fetch them with ` +
|
|
107
|
+
`\`homespun participants ${appId}\`.${r}`);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const label = humanUrls.length === 1 ? "Open this link" : "Open these links";
|
|
111
|
+
lines.push(`${label} in a browser:`);
|
|
112
|
+
lines.push("");
|
|
113
|
+
for (const u of humanUrls) {
|
|
114
|
+
lines.push(` ${b}${u}${r}`);
|
|
115
|
+
}
|
|
116
|
+
lines.push("");
|
|
117
|
+
// Show a QR for the first URL — scannable from a phone. The other
|
|
118
|
+
// URLs (if any) are visible above; one QR keeps the output compact.
|
|
119
|
+
lines.push(`Or scan this QR code with your phone:`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
const qr = qrToString(humanUrls[0]);
|
|
122
|
+
// Indent each QR line by two spaces so it sits inside the same gutter
|
|
123
|
+
// as the rest of the body.
|
|
124
|
+
for (const ln of qr.split("\n")) {
|
|
125
|
+
if (ln.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
lines.push(` ${ln}`);
|
|
128
|
+
}
|
|
129
|
+
lines.push("");
|
|
130
|
+
}
|
|
131
|
+
lines.push(`${d}Agent stream:${r} ${agentStream}`);
|
|
132
|
+
return lines.join("\n") + "\n";
|
|
133
|
+
}
|