flostep 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/AGENTS.md +105 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/flostep.js +6 -0
- package/package.json +36 -0
- package/src/api.js +197 -0
- package/src/browser.js +46 -0
- package/src/cli.js +264 -0
- package/src/code.js +151 -0
- package/src/commands/create.js +55 -0
- package/src/commands/delete.js +59 -0
- package/src/commands/folder.js +141 -0
- package/src/commands/init.js +106 -0
- package/src/commands/list.js +56 -0
- package/src/commands/login.js +166 -0
- package/src/commands/logout.js +30 -0
- package/src/commands/move.js +48 -0
- package/src/commands/node.js +87 -0
- package/src/commands/open.js +29 -0
- package/src/commands/share.js +47 -0
- package/src/commands/show.js +30 -0
- package/src/commands/step.js +129 -0
- package/src/commands/syntax.js +17 -0
- package/src/commands/update.js +71 -0
- package/src/commands/whoami.js +39 -0
- package/src/config.js +67 -0
- package/src/errors.js +26 -0
- package/src/instructions.js +121 -0
- package/src/output.js +170 -0
- package/src/source.js +63 -0
- package/src/stdin.js +29 -0
- package/src/target.js +30 -0
- package/src/version.js +17 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// `flostep step` — edit one step of an existing diagram without a local file.
|
|
2
|
+
//
|
|
3
|
+
// This is the shape an agent works in: it has a diagram id and one thing to
|
|
4
|
+
// change, not a checkout. Everything here is read-modify-write over the same
|
|
5
|
+
// two endpoints `show` and `update` use — GET the code, transform the text, PUT it back —
|
|
6
|
+
// so there is no second way for a diagram to be written and nothing new to keep
|
|
7
|
+
// in sync on the server.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import { UsageError } from "../errors.js";
|
|
11
|
+
import { out, ok, table, json, dim } from "../output.js";
|
|
12
|
+
import { resolveTarget } from "../target.js";
|
|
13
|
+
import { addStep, removeStep, parseSteps, formatStep } from "../code.js";
|
|
14
|
+
|
|
15
|
+
const SUBCOMMANDS = ["add", "rm", "list"];
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
name: "step",
|
|
19
|
+
usage: [
|
|
20
|
+
'flostep step add <id> "From -> To: what happens" [--at <n>]',
|
|
21
|
+
"flostep step rm <id> <n>",
|
|
22
|
+
"flostep step list <id> [--json]"
|
|
23
|
+
],
|
|
24
|
+
details: [
|
|
25
|
+
"Steps are numbered from 1, matching the badges on the canvas.",
|
|
26
|
+
"Components are created by being named, so `step add` is also how a box appears."
|
|
27
|
+
],
|
|
28
|
+
options: { at: { type: "string" } },
|
|
29
|
+
optionHelp: [["--at <n>", "insert before step n instead of appending"]],
|
|
30
|
+
examples: [
|
|
31
|
+
['flostep step add 15 "API -> Cache: read session"'],
|
|
32
|
+
['flostep step add 15 "Client -> API: retry" --at 3'],
|
|
33
|
+
["flostep step list 15 --json"],
|
|
34
|
+
["flostep step rm 15 4"]
|
|
35
|
+
],
|
|
36
|
+
|
|
37
|
+
async run({ positionals, values, ctx }) {
|
|
38
|
+
const [subcommand, ref, ...rest] = positionals;
|
|
39
|
+
|
|
40
|
+
if (!SUBCOMMANDS.includes(subcommand)) {
|
|
41
|
+
throw new UsageError(
|
|
42
|
+
subcommand ? `Unknown subcommand "step ${subcommand}".` : "Which action? add, rm or list.",
|
|
43
|
+
{ usage: this.usage.join("\n ") }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const target = resolveTarget(ref);
|
|
48
|
+
const api = ctx.api();
|
|
49
|
+
const diagram = await api.getDiagram(target.id);
|
|
50
|
+
|
|
51
|
+
if (subcommand === "list") return list({ diagram, ctx });
|
|
52
|
+
|
|
53
|
+
const updated =
|
|
54
|
+
subcommand === "add"
|
|
55
|
+
? applyAdd({ code: diagram.code, text: rest.join(" "), at: values.at })
|
|
56
|
+
: applyRemove({ code: diagram.code, n: rest[0] });
|
|
57
|
+
|
|
58
|
+
// The version from the read above, so a change made in between (in the
|
|
59
|
+
// browser, by a teammate, by another agent) is refused rather than erased.
|
|
60
|
+
await api.updateDiagram(target.id, { code: updated, version: diagram.version });
|
|
61
|
+
|
|
62
|
+
if (ctx.json) {
|
|
63
|
+
json({ id: diagram.id, title: diagram.title, code: updated, steps: parseSteps(updated).length });
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const count = parseSteps(updated).length;
|
|
68
|
+
ok(
|
|
69
|
+
subcommand === "add"
|
|
70
|
+
? `Added step to #${diagram.id} ${dim(`(${count} steps)`)}`
|
|
71
|
+
: `Removed step ${rest[0]} from #${diagram.id} ${dim(`(${count} steps)`)}`
|
|
72
|
+
);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
function applyAdd({ code, text, at }) {
|
|
78
|
+
if (!text.trim()) {
|
|
79
|
+
throw new UsageError('What step? Pass it in quotes: "From -> To: what happens".', {
|
|
80
|
+
usage: 'flostep step add <id> "From -> To: what happens"'
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let position;
|
|
85
|
+
if (at !== undefined) {
|
|
86
|
+
position = Number(at);
|
|
87
|
+
if (!Number.isInteger(position)) {
|
|
88
|
+
throw new UsageError("--at takes a step number.", { usage: "flostep step add <id> <step> --at <n>" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// code.js raises plain Errors with sentences already written for a reader;
|
|
93
|
+
// cli.js prints them and exits 1.
|
|
94
|
+
return addStep(code, text, { at: position });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function applyRemove({ code, n }) {
|
|
98
|
+
const index = Number(n);
|
|
99
|
+
if (!Number.isInteger(index)) {
|
|
100
|
+
throw new UsageError("Which step? Pass its number — see `flostep step list`.", {
|
|
101
|
+
usage: "flostep step rm <id> <n>"
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return removeStep(code, index);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
function list({ diagram, ctx }) {
|
|
109
|
+
const steps = parseSteps(diagram.code);
|
|
110
|
+
|
|
111
|
+
if (ctx.json) {
|
|
112
|
+
json(steps.map(({ from, to, label }, i) => ({ n: i + 1, from, to, label })));
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (steps.length === 0) {
|
|
117
|
+
out(dim("No steps yet."));
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
table(
|
|
122
|
+
steps.map((step, i) => ({ ...step, n: i + 1 })),
|
|
123
|
+
[
|
|
124
|
+
["#", (s) => s.n, "right"],
|
|
125
|
+
["STEP", (s) => formatStep(s)]
|
|
126
|
+
]
|
|
127
|
+
);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { out } from "../output.js";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: "syntax",
|
|
5
|
+
usage: "flostep syntax",
|
|
6
|
+
details: [
|
|
7
|
+
"Prints the step grammar, fetched from the server rather than hard-coded here,",
|
|
8
|
+
"so it can't drift from what the parser actually accepts."
|
|
9
|
+
],
|
|
10
|
+
examples: [["flostep syntax", "the format `create`, `update` and `step add` expect"]],
|
|
11
|
+
|
|
12
|
+
async run({ ctx }) {
|
|
13
|
+
const { syntax } = await ctx.api().syntax();
|
|
14
|
+
out(syntax);
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `flostep update` — replace a diagram's steps from a pipe.
|
|
2
|
+
//
|
|
3
|
+
// The counterpart to `create`, and the composable half of the tool:
|
|
4
|
+
// flostep show 42 | sed 's/Redis/Session Cache/' | flostep update 42
|
|
5
|
+
|
|
6
|
+
import { readStdin } from "../stdin.js";
|
|
7
|
+
import { UsageError } from "../errors.js";
|
|
8
|
+
import { out, ok, json, dim } from "../output.js";
|
|
9
|
+
import { resolveTarget } from "../target.js";
|
|
10
|
+
import { parseSteps } from "../code.js";
|
|
11
|
+
import { diagramFromStdin, describe } from "../source.js";
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
name: "update",
|
|
15
|
+
usage: [
|
|
16
|
+
"flostep update <id> [--title <title>] [--if-version <n>]",
|
|
17
|
+
"flostep show 42 | flostep update 42"
|
|
18
|
+
],
|
|
19
|
+
details: [
|
|
20
|
+
"Reads steps from stdin and replaces every step in the diagram.",
|
|
21
|
+
"Read it first — this discards whatever was there.",
|
|
22
|
+
"Pass the `version` from `flostep show <id> --json` as --if-version, and the",
|
|
23
|
+
"write is refused if anyone changed the diagram since you read it."
|
|
24
|
+
],
|
|
25
|
+
options: {
|
|
26
|
+
title: { type: "string" },
|
|
27
|
+
"if-version": { type: "string" }
|
|
28
|
+
},
|
|
29
|
+
optionHelp: [
|
|
30
|
+
["--title <title>", "also rename the diagram"],
|
|
31
|
+
["--if-version <n>", "only write if the diagram is still at this version"]
|
|
32
|
+
],
|
|
33
|
+
examples: [
|
|
34
|
+
["flostep show 42 | sed 's/Redis/Cache/' | flostep update 42"],
|
|
35
|
+
["flostep update 42 < revised.txt"],
|
|
36
|
+
["flostep update 42 --if-version 7 < revised.txt", "refused if it changed since version 7"]
|
|
37
|
+
],
|
|
38
|
+
|
|
39
|
+
async run({ positionals, values, ctx }) {
|
|
40
|
+
const { id } = resolveTarget(positionals[0]);
|
|
41
|
+
const version = parseVersion(values["if-version"]);
|
|
42
|
+
const input = await readStdin({ what: "`flostep update 42 < flow.txt`" });
|
|
43
|
+
const { code, summary } = diagramFromStdin(input);
|
|
44
|
+
|
|
45
|
+
const body = { code };
|
|
46
|
+
if (values.title) body.title = values.title;
|
|
47
|
+
if (version !== undefined) body.version = version;
|
|
48
|
+
const diagram = await ctx.api().updateDiagram(id, body);
|
|
49
|
+
|
|
50
|
+
if (ctx.json) {
|
|
51
|
+
json({ ...diagram, steps: parseSteps(code).length });
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ok(`Updated #${diagram.id} ${dim(`(${describe(summary)})`)}`);
|
|
56
|
+
out(diagram.url);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Checked before stdin is read, so a typo fails as usage (exit 2) without
|
|
62
|
+
// consuming the pipe.
|
|
63
|
+
function parseVersion(raw) {
|
|
64
|
+
if (raw === undefined) return undefined;
|
|
65
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw.trim())) {
|
|
66
|
+
throw new UsageError("--if-version takes the number from `flostep show <id> --json`.", {
|
|
67
|
+
usage: "flostep update <id> --if-version <n>"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return Number(raw);
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { resolveToken } from "../config.js";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
import { out, json, dim, bold } from "../output.js";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
name: "whoami",
|
|
7
|
+
usage: "flostep whoami",
|
|
8
|
+
details: [
|
|
9
|
+
"A key belongs to you but acts on the whole workspace — anything it creates",
|
|
10
|
+
"belongs to the team. This is how you check which library you're writing to."
|
|
11
|
+
],
|
|
12
|
+
examples: [
|
|
13
|
+
["flostep whoami"],
|
|
14
|
+
["flostep whoami --json", "for scripts and agents"]
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
async run({ ctx }) {
|
|
18
|
+
const { token, source } = resolveToken();
|
|
19
|
+
if (!token) throw new CliError("Not logged in.", { hint: "Run `flostep login` first." });
|
|
20
|
+
|
|
21
|
+
const me = await ctx.api().me();
|
|
22
|
+
|
|
23
|
+
if (ctx.json) {
|
|
24
|
+
json({ ...me, credential_source: source });
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
out(`${bold(me.email)}${me.name && me.name !== me.email ? dim(` (${me.name})`) : ""}`);
|
|
29
|
+
out(`${dim("Workspace")} ${me.workspace.name}${me.workspace.team ? dim(" (team)") : ""}`);
|
|
30
|
+
out(`${dim("Plan")} ${me.workspace.plan}`);
|
|
31
|
+
out(
|
|
32
|
+
`${dim("Diagrams")} ${
|
|
33
|
+
me.diagrams_remaining === null ? "unlimited" : `${me.diagrams_remaining} remaining on this plan`
|
|
34
|
+
}`
|
|
35
|
+
);
|
|
36
|
+
out(dim(`Authenticated from ${source === "FLOSTEP_TOKEN" ? "$FLOSTEP_TOKEN" : "the stored login"}.`));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Where the CLI remembers who you are between runs.
|
|
2
|
+
//
|
|
3
|
+
// The environment always wins over the file. That ordering is what lets one
|
|
4
|
+
// machine be logged in as a person and still run a CI-shaped command against a
|
|
5
|
+
// service token without logging out first — and it is the only path that works
|
|
6
|
+
// in CI, where there is no browser to complete a device flow.
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_HOST = "https://flostep.dev";
|
|
13
|
+
|
|
14
|
+
// XDG first, so anyone who has moved their config directory is respected;
|
|
15
|
+
// ~/.config is the fallback rather than ~/.flostep because a dotfile per tool
|
|
16
|
+
// in the home directory is what XDG exists to stop.
|
|
17
|
+
function configDir() {
|
|
18
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
19
|
+
return join(base, "flostep");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function configPath() {
|
|
23
|
+
return join(configDir(), "config.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readConfig() {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(readFileSync(configPath(), "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
// Missing is the normal case before the first login, and unreadable or
|
|
31
|
+
// corrupt is recovered by logging in again — neither is worth an error the
|
|
32
|
+
// user has to act on.
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeConfig(config) {
|
|
38
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
39
|
+
// The file holds a bearer token with full workspace read/write, so it is
|
|
40
|
+
// written 0600 rather than left at the umask default. mode: on writeFileSync
|
|
41
|
+
// only applies when the file is created, so chmod-on-every-write is deliberate
|
|
42
|
+
// — an existing file created before this rule still gets tightened.
|
|
43
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearConfig() {
|
|
47
|
+
if (existsSync(configPath())) rmSync(configPath());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// FLOSTEP_TOKEN wins. In CI it is the only credential there is.
|
|
51
|
+
export function resolveToken() {
|
|
52
|
+
const fromEnv = process.env.FLOSTEP_TOKEN?.trim();
|
|
53
|
+
if (fromEnv) return { token: fromEnv, source: "FLOSTEP_TOKEN" };
|
|
54
|
+
|
|
55
|
+
const { token } = readConfig();
|
|
56
|
+
return token ? { token, source: "config" } : { token: null, source: null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// FLOSTEP_API_URL is deliberately undocumented: there is one Flostep server,
|
|
60
|
+
// and the variable exists for developing against a local one and for the
|
|
61
|
+
// tests. The stored host is next, so a login against localhost keeps working
|
|
62
|
+
// for the rest of that session without the variable set.
|
|
63
|
+
export function resolveHost() {
|
|
64
|
+
const host = process.env.FLOSTEP_API_URL || readConfig().host || DEFAULT_HOST;
|
|
65
|
+
|
|
66
|
+
return host.replace(/\/+$/, "");
|
|
67
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Two failure kinds, because they mean different things to whoever is reading.
|
|
2
|
+
//
|
|
3
|
+
// A UsageError is the caller getting the command wrong — it prints the usage
|
|
4
|
+
// line and exits 2. A CliError is the command being right and the world being
|
|
5
|
+
// wrong (no network, no permission, diagram not found) — it prints the message
|
|
6
|
+
// and exits 1. Scripts and agents branch on that difference; a single exit code
|
|
7
|
+
// for both would make "I typo'd the flag" indistinguishable from "the diagram
|
|
8
|
+
// is gone".
|
|
9
|
+
|
|
10
|
+
export class CliError extends Error {
|
|
11
|
+
constructor(message, { hint } = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "CliError";
|
|
14
|
+
this.exitCode = 1;
|
|
15
|
+
this.hint = hint;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class UsageError extends Error {
|
|
20
|
+
constructor(message, { usage } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "UsageError";
|
|
23
|
+
this.exitCode = 2;
|
|
24
|
+
this.usage = usage;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Writing the AGENTS.md block into someone else's repository.
|
|
2
|
+
//
|
|
3
|
+
// The block is the half of AGENTS.md below its `---` rule, read from the file
|
|
4
|
+
// this package ships, so `flostep init` and the copy-paste instructions can't
|
|
5
|
+
// drift apart.
|
|
6
|
+
//
|
|
7
|
+
// It goes between marker comments. Re-running `init` after an upgrade replaces
|
|
8
|
+
// what is between them and nothing else — the file around it is the user's, and
|
|
9
|
+
// appending a second copy on every run would be worse than doing nothing.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const BEGIN = "<!-- flostep:begin — managed by `npx flostep init`; re-run it to update -->";
|
|
16
|
+
const END = "<!-- flostep:end -->";
|
|
17
|
+
const MANAGED = /<!-- flostep:begin[^>]*-->[\s\S]*?<!-- flostep:end -->/;
|
|
18
|
+
|
|
19
|
+
// What a hand-pasted copy of the block looks like. Its presence without the
|
|
20
|
+
// markers means someone already did this by hand, and adding a second copy
|
|
21
|
+
// would leave the agent reading the instructions twice.
|
|
22
|
+
const PASTED = /^## Flostep CLI\s*$/m;
|
|
23
|
+
|
|
24
|
+
const CURSOR_FRONTMATTER = [
|
|
25
|
+
"---",
|
|
26
|
+
"description: Flostep CLI — build, update and share step-through diagrams of how something works",
|
|
27
|
+
"alwaysApply: false",
|
|
28
|
+
"---",
|
|
29
|
+
""
|
|
30
|
+
].join("\n");
|
|
31
|
+
|
|
32
|
+
export function agentBlock(source = join(dirname(fileURLToPath(import.meta.url)), "..", "AGENTS.md")) {
|
|
33
|
+
const text = readFileSync(source, "utf8").replace(/\r\n/g, "\n");
|
|
34
|
+
const rule = text.search(/^---$/m);
|
|
35
|
+
if (rule === -1) throw new Error(`${source} has no \`---\` separating the preamble from the block.`);
|
|
36
|
+
return text.slice(rule + 3).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Same walk as the manifest: the repo root if there is one, otherwise where the
|
|
40
|
+
// command was run.
|
|
41
|
+
export function projectRoot(startDir = process.cwd()) {
|
|
42
|
+
let dir = resolve(startDir);
|
|
43
|
+
for (;;) {
|
|
44
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
45
|
+
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return resolve(startDir);
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const isFile = (path) => existsSync(path) && statSync(path).isFile();
|
|
53
|
+
const isDir = (path) => existsSync(path) && statSync(path).isDirectory();
|
|
54
|
+
|
|
55
|
+
// Every instruction file the repo already has, because a team using both Claude
|
|
56
|
+
// Code and Cursor wants flostep discoverable from both. AGENTS.md is the
|
|
57
|
+
// fallback when there are none — it's the one read by the most tools.
|
|
58
|
+
export function detectTargets(root) {
|
|
59
|
+
const agents = join(root, "AGENTS.md");
|
|
60
|
+
const claude = join(root, "CLAUDE.md");
|
|
61
|
+
const cursorRules = join(root, ".cursor", "rules");
|
|
62
|
+
|
|
63
|
+
const targets = [];
|
|
64
|
+
if (isFile(agents)) targets.push(agents);
|
|
65
|
+
|
|
66
|
+
// A CLAUDE.md that imports AGENTS.md already sees the block; writing it into
|
|
67
|
+
// both would put it in the context twice.
|
|
68
|
+
if (isFile(claude) && !(isFile(agents) && /^\s*@AGENTS\.md\b/m.test(readFileSync(claude, "utf8")))) {
|
|
69
|
+
targets.push(claude);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (isDir(cursorRules)) targets.push(join(cursorRules, "flostep.mdc"));
|
|
73
|
+
|
|
74
|
+
return targets.length > 0 ? targets : [ agents ];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pure, so every case is testable without touching disk. Returns the new
|
|
78
|
+
// contents and what happened; `contents` is null when nothing should be written.
|
|
79
|
+
export function applyBlock(existing, block, { cursor = false } = {}) {
|
|
80
|
+
const eol = existing?.includes("\r\n") ? "\r\n" : "\n";
|
|
81
|
+
const managed = `${BEGIN}\n${block}\n${END}`.replace(/\n/g, eol);
|
|
82
|
+
|
|
83
|
+
if (existing == null) {
|
|
84
|
+
const prefix = cursor ? CURSOR_FRONTMATTER : "";
|
|
85
|
+
return { action: "created", contents: `${prefix}${managed}\n` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (MANAGED.test(existing)) {
|
|
89
|
+
// A function replacement, so a `$` in the block isn't read as a pattern.
|
|
90
|
+
const contents = existing.replace(MANAGED, () => managed);
|
|
91
|
+
return contents === existing
|
|
92
|
+
? { action: "unchanged", contents: null }
|
|
93
|
+
: { action: "updated", contents };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (PASTED.test(existing)) return { action: "skipped", contents: null };
|
|
97
|
+
|
|
98
|
+
// One blank line between the user's content and the block, however their
|
|
99
|
+
// file happened to end.
|
|
100
|
+
const separator =
|
|
101
|
+
existing.trim() === "" ? "" : existing.endsWith(eol + eol) ? "" : existing.endsWith(eol) ? eol : eol + eol;
|
|
102
|
+
return { action: "appended", contents: `${existing}${separator}${managed}${eol}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Split from writing so `init` can show what it's about to change and ask first.
|
|
106
|
+
export function planBlock(path, block) {
|
|
107
|
+
const existing = isFile(path) ? readFileSync(path, "utf8") : null;
|
|
108
|
+
return { path, ...applyBlock(existing, block, { cursor: path.endsWith(".mdc") }) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function commitPlan({ path, contents }) {
|
|
112
|
+
if (contents == null) return;
|
|
113
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
114
|
+
writeFileSync(path, contents);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function writeBlock(path, block) {
|
|
118
|
+
const plan = planBlock(path, block);
|
|
119
|
+
commitPlan(plan);
|
|
120
|
+
return plan.action;
|
|
121
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Printing, in two registers: one for a person at a terminal, one for whatever
|
|
2
|
+
// is parsing this.
|
|
3
|
+
//
|
|
4
|
+
// Everything an agent or a script needs goes to stdout as JSON under --json;
|
|
5
|
+
// everything a person needs goes to stdout as text. Errors and progress always
|
|
6
|
+
// go to stderr, so `flostep show 15 > flow.flostep` writes only the diagram
|
|
7
|
+
// even while the command is chattering about what it's doing.
|
|
8
|
+
|
|
9
|
+
const useColor =
|
|
10
|
+
process.stdout.isTTY &&
|
|
11
|
+
!process.env.NO_COLOR &&
|
|
12
|
+
process.env.TERM !== "dumb";
|
|
13
|
+
|
|
14
|
+
// Written as an escape rather than a literal control byte, so the source
|
|
15
|
+
// stays greppable and survives anything that strips them.
|
|
16
|
+
const ESC = "\u001b";
|
|
17
|
+
const wrap = (code) => (text) => (useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text);
|
|
18
|
+
|
|
19
|
+
export const dim = wrap("2");
|
|
20
|
+
export const bold = wrap("1");
|
|
21
|
+
export const green = wrap("32");
|
|
22
|
+
export const red = wrap("31");
|
|
23
|
+
export const cyan = wrap("36");
|
|
24
|
+
export const yellow = wrap("33");
|
|
25
|
+
|
|
26
|
+
// `flostep show 42 | head -5` closes the pipe while the CLI is still writing,
|
|
27
|
+
// and Node turns that into an unhandled 'error' event — a stack trace and a
|
|
28
|
+
// non-zero exit for what is a completely ordinary shell idiom. Every tool that
|
|
29
|
+
// can be piped has to say this; the shell convention is to stop quietly.
|
|
30
|
+
for (const stream of [ process.stdout, process.stderr ]) {
|
|
31
|
+
stream.on("error", (error) => {
|
|
32
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function out(line = "") {
|
|
38
|
+
process.stdout.write(`${line}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Progress and status go to stderr so that piping stdout gives you only the
|
|
42
|
+
// payload — `flostep show 15 > flow.flostep` must not capture "✓ Fetched".
|
|
43
|
+
export function note(line = "") {
|
|
44
|
+
process.stderr.write(`${line}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ok(message) {
|
|
48
|
+
note(`${green("✓")} ${message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function warn(message) {
|
|
52
|
+
note(`${yellow("!")} ${message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function fail(message, hint) {
|
|
56
|
+
note(`${red("✗")} ${message}`);
|
|
57
|
+
if (hint) note(` ${dim(hint)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function json(value) {
|
|
61
|
+
out(JSON.stringify(value, null, 2));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Columns are [header, accessor, align?]. Widths are measured, so a long title
|
|
65
|
+
// doesn't push the rest out of true.
|
|
66
|
+
export function table(rows, columns) {
|
|
67
|
+
if (rows.length === 0) return;
|
|
68
|
+
|
|
69
|
+
const cells = rows.map((row) => columns.map(([, get]) => String(get(row) ?? "")));
|
|
70
|
+
const widths = columns.map(([header], i) =>
|
|
71
|
+
Math.max(header.length, ...cells.map((row) => row[i].length))
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const render = (values, style = (s) => s) =>
|
|
75
|
+
out(
|
|
76
|
+
values
|
|
77
|
+
.map((value, i) =>
|
|
78
|
+
style(columns[i][2] === "right" ? value.padStart(widths[i]) : value.padEnd(widths[i]))
|
|
79
|
+
)
|
|
80
|
+
.join(" ")
|
|
81
|
+
.trimEnd()
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
render(columns.map(([header]) => header), dim);
|
|
85
|
+
cells.forEach((row) => render(row));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Full timestamps are noise in a table; no timestamp at all is useless. This is
|
|
89
|
+
// coarse on purpose — the question a list answers is "which did I touch last?"
|
|
90
|
+
export function relativeTime(iso) {
|
|
91
|
+
if (!iso) return "";
|
|
92
|
+
|
|
93
|
+
const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
94
|
+
if (seconds < 60) return "just now";
|
|
95
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
96
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
97
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Width ignoring colour codes, so a styled string still measures as what the
|
|
101
|
+
// eye sees. Everything that aligns or boxes text has to go through this.
|
|
102
|
+
function visibleLength(text) {
|
|
103
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A rounded box, drawn to stderr. Used for the one thing in a login someone has
|
|
107
|
+
// to read off the screen and retype — a code buried in a paragraph gets
|
|
108
|
+
// mistyped, and the box is what makes it findable at a glance.
|
|
109
|
+
export function box(lines, { indent = " " } = {}) {
|
|
110
|
+
const width = Math.max(...lines.map(visibleLength)) + 4;
|
|
111
|
+
const bar = "─".repeat(width);
|
|
112
|
+
|
|
113
|
+
note(`${indent}${dim("╭" + bar + "╮")}`);
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
const padding = " ".repeat(width - visibleLength(line) - 4);
|
|
116
|
+
note(`${indent}${dim("│")} ${line}${padding} ${dim("│")}`);
|
|
117
|
+
}
|
|
118
|
+
note(`${indent}${dim("╰" + bar + "╯")}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Label/value rows, aligned. Same idea as `table` but for a single record.
|
|
122
|
+
export function fields(rows, { indent = " " } = {}) {
|
|
123
|
+
const width = Math.max(...rows.map(([label]) => label.length));
|
|
124
|
+
for (const [label, value] of rows) {
|
|
125
|
+
note(`${indent}${dim(label.padEnd(width))} ${value}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A braille spinner, and the reason it exists: polling a device grant takes as
|
|
130
|
+
// long as someone takes to reach their browser, and a static line for 30
|
|
131
|
+
// seconds reads as a hang.
|
|
132
|
+
//
|
|
133
|
+
// Silent when stderr is not a TTY — in CI the frames would land as thousands of
|
|
134
|
+
// lines of escape codes in the log. The cursor is hidden while it runs, so the
|
|
135
|
+
// stop function has to be reachable on every path out, including Ctrl-C.
|
|
136
|
+
const SPINNER_FRAMES = [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ];
|
|
137
|
+
|
|
138
|
+
export function startSpinner(text) {
|
|
139
|
+
if (!process.stderr.isTTY) {
|
|
140
|
+
note(` ${text}`);
|
|
141
|
+
return () => {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let frame = 0;
|
|
145
|
+
const render = () => {
|
|
146
|
+
process.stderr.write(`\r ${cyan(SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length])} ${dim(text)}`);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
process.stderr.write("\u001b[?25l");
|
|
150
|
+
render();
|
|
151
|
+
const timer = setInterval(render, 80);
|
|
152
|
+
|
|
153
|
+
let stopped = false;
|
|
154
|
+
const stop = () => {
|
|
155
|
+
if (stopped) return;
|
|
156
|
+
stopped = true;
|
|
157
|
+
clearInterval(timer);
|
|
158
|
+
process.stderr.write("\r\u001b[2K\u001b[?25h");
|
|
159
|
+
process.off("SIGINT", onInterrupt);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Without this, Ctrl-C during a login leaves the terminal with no cursor.
|
|
163
|
+
function onInterrupt() {
|
|
164
|
+
stop();
|
|
165
|
+
process.exit(130);
|
|
166
|
+
}
|
|
167
|
+
process.once("SIGINT", onInterrupt);
|
|
168
|
+
|
|
169
|
+
return stop;
|
|
170
|
+
}
|