Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
`firstdraft` is the command-line client for [First Draft](https://github.com/firstdraft/firstdraft). It is being
built for agents that author and review Foundation Plans with their users.

The package is not released yet. This repository currently contains only the auditable command shell; Plan and
network behavior will arrive in reviewed increments.
The package is not released yet. This repository contains the auditable command shell and local Foundation Plan
initialization; API and release behavior will arrive in reviewed increments.

## Requirements

Expand All @@ -19,11 +19,22 @@ npm run check
npm run pack:check
```

## Start a Foundation Plan

From the project that the Plan describes:

```sh
firstdraft plan init --application-key oscar_party --name "Oscar Party"
```

This creates an empty `sketch/0.19` Plan and client-generated Project ID under `.firstdraft/`. A nested ignore file
keeps that local scratch area out of Git without changing the project's own `.gitignore`. Initialization makes no
network request and refuses to replace an existing `.firstdraft` path.

## Trust model

- The published CLI will run the reviewed JavaScript source directly, without generated or bundled code.
- The command shell has no runtime dependencies, install scripts, telemetry, update checks, or implicit network
activity.
- The CLI has no runtime dependencies, install scripts, telemetry, update checks, or implicit network activity.
- Package contents are allowlisted and checked before release.
- CI exercises the exact minimum Node.js version separately from current development tooling.
- Public releases will use npm provenance after the first useful version bootstraps trusted publishing.
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ if (result.status !== 0) {
"bin/firstdraft.js",
"package.json",
"src/cli.js",
"src/commands/plan-init.js",
"src/uuid-v7.js",
"src/version.js",
]);
}
277 changes: 262 additions & 15 deletions src/cli.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,59 @@
import { parseArgs } from "node:util";

import { initializePlan, isFileSystemError } from "./commands/plan-init.js";
import { generateUuidV7 } from "./uuid-v7.js";
import { VERSION } from "./version.js";

const HELP = `First Draft CLI
const ROOT_HELP = `First Draft CLI

Usage:
firstdraft <command> [options]
firstdraft [options]

Commands:
plan Work with Foundation Plans

Options:
-h, --help Show help
-V, --version Show version
`;

const USAGE_ERROR = "Invalid arguments.\nRun 'firstdraft --help' for usage.\n";
const UNKNOWN_COMMAND =
const PLAN_HELP = `First Draft CLI

Usage:
firstdraft plan <command> [options]

Commands:
init Create a local empty Foundation Plan

Options:
-h, --help Show help
`;

const PLAN_INIT_HELP = `First Draft CLI

Usage:
firstdraft plan init --application-key <key> --name <name>

Options:
--application-key <key> Lower-snake-case application key
--name <name> Application display name
-h, --help Show help
`;

const ROOT_USAGE_ERROR =
"Invalid arguments.\nRun 'firstdraft --help' for usage.\n";
const ROOT_UNKNOWN_COMMAND =
"Unknown command.\nRun 'firstdraft --help' for usage.\n";
const PLAN_USAGE_ERROR =
"Invalid arguments.\nRun 'firstdraft plan --help' for usage.\n";
const PLAN_UNKNOWN_COMMAND =
"Unknown command.\nRun 'firstdraft plan --help' for usage.\n";
const PLAN_INIT_USAGE_ERROR =
"Invalid arguments.\nRun 'firstdraft plan init --help' for usage.\n";
const PLAN_INIT_ERROR =
"Could not initialize .firstdraft. The directory may be incomplete; no existing files were overwritten.\n";
const PLAN_INIT_SUCCESS = "Initialized .firstdraft/foundation-plan.json.\n";

/**
* @typedef {object} Writer
Expand All @@ -26,41 +65,75 @@ const UNKNOWN_COMMAND =
* @property {readonly string[]} argv
* @property {Writer} stdout
* @property {Writer} stderr
* @property {string} [cwd]
* @property {() => string} [createProjectId]
* @property {import("./commands/plan-init.js").FileSystem} [fileSystem]
*/

/**
* @typedef {object} CommandOptions
* @property {readonly string[]} argv
* @property {Writer} stdout
* @property {Writer} stderr
* @property {string} cwd
* @property {() => string} createProjectId
* @property {import("./commands/plan-init.js").FileSystem} [fileSystem]
*/

/** @param {RunOptions} options */
export function run({ argv, stdout, stderr }) {
let parsed;
export function run({
argv,
stdout,
stderr,
cwd = process.cwd(),
createProjectId = generateUuidV7,
fileSystem,
}) {
if (argv[0] === "plan") {
return runPlan({
argv: argv.slice(1),
stdout,
stderr,
cwd,
createProjectId,
fileSystem,
});
}

try {
parsed = parseArgs({
return runRoot({ argv, stdout, stderr });
}

/** @param {Pick<RunOptions, "argv" | "stdout" | "stderr">} options */
function runRoot({ argv, stdout, stderr }) {
const parsed = parseArguments(() =>
parseArgs({
args: [...argv],
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "V" },
},
allowPositionals: true,
strict: true,
});
} catch (error) {
if (!isParseArgsError(error)) throw error;
}),
);

stderr.write(USAGE_ERROR);
if (!parsed) {
stderr.write(ROOT_USAGE_ERROR);
return 2;
}

if (argv.length === 0) {
stdout.write(HELP);
stdout.write(ROOT_HELP);
return 0;
}

if (parsed.positionals.length > 0) {
stderr.write(UNKNOWN_COMMAND);
stderr.write(ROOT_UNKNOWN_COMMAND);
return 2;
}

if (parsed.values.help) {
stdout.write(HELP);
stdout.write(ROOT_HELP);
return 0;
}

Expand All @@ -69,10 +142,184 @@ export function run({ argv, stdout, stderr }) {
return 0;
}

stdout.write(HELP);
stdout.write(ROOT_HELP);
return 0;
}

/** @param {CommandOptions} options */
function runPlan({ argv, stdout, stderr, cwd, createProjectId, fileSystem }) {
if (argv[0] === "init") {
return runPlanInit({
argv: argv.slice(1),
stdout,
stderr,
cwd,
createProjectId,
fileSystem,
});
}

const parsed = parseArguments(() =>
parseArgs({
args: [...argv],
options: { help: { type: "boolean", short: "h" } },
allowPositionals: true,
strict: true,
}),
);

if (!parsed) {
stderr.write(PLAN_USAGE_ERROR);
return 2;
}

if (parsed.positionals.length > 0) {
stderr.write(PLAN_UNKNOWN_COMMAND);
return 2;
}

if (argv.length === 0 || parsed.values.help) {
stdout.write(PLAN_HELP);
return 0;
}

stdout.write(PLAN_HELP);
return 0;
}

/** @param {CommandOptions} options */
function runPlanInit({
argv,
stdout,
stderr,
cwd,
createProjectId,
fileSystem,
}) {
const parsed = parseArguments(() =>
parseArgs({
args: [...argv],
options: {
"application-key": { type: "string" },
name: { type: "string" },
help: { type: "boolean", short: "h" },
},
allowPositionals: false,
strict: true,
tokens: true,
}),
);

if (!parsed || repeatedValueOption(parsed.tokens)) {
stderr.write(PLAN_INIT_USAGE_ERROR);
return 2;
}

if (parsed.values.help) {
stdout.write(PLAN_INIT_HELP);
return 0;
}

const applicationKey = parsed.values["application-key"];
const name = parsed.values.name;
if (
typeof applicationKey !== "string" ||
!/^[a-z][a-z0-9_]*$/.test(applicationKey) ||
typeof name !== "string" ||
!isValidApplicationName(name)
) {
stderr.write(PLAN_INIT_USAGE_ERROR);
return 2;
}

const projectId = createProjectId();

try {
initializePlan({
applicationKey,
name,
projectId,
cwd,
fileSystem,
});
} catch (error) {
if (!isFileSystemError(error)) throw error;

stderr.write(PLAN_INIT_ERROR);
return 1;
}

stdout.write(PLAN_INIT_SUCCESS);
return 0;
}

/** @param {string} name */
function isValidApplicationName(name) {
let hasNonWhitespace = false;

for (const character of name) {
const codePoint = character.codePointAt(0) ?? 0;
if (
codePoint === 0 ||
(codePoint >= 0xd800 && codePoint <= 0xdfff) ||
(codePoint >= 0xfdd0 && codePoint <= 0xfdef) ||
(codePoint & 0xfffe) === 0xfffe
) {
return false;
}

if (!isUnicodeWhitespace(codePoint)) hasNonWhitespace = true;
}

return hasNonWhitespace;
}

/** @param {number} codePoint */
function isUnicodeWhitespace(codePoint) {
return (
(codePoint >= 0x0009 && codePoint <= 0x000d) ||
codePoint === 0x0020 ||
codePoint === 0x0085 ||
codePoint === 0x00a0 ||
codePoint === 0x1680 ||
(codePoint >= 0x2000 && codePoint <= 0x200a) ||
codePoint === 0x2028 ||
codePoint === 0x2029 ||
codePoint === 0x202f ||
codePoint === 0x205f ||
codePoint === 0x3000
);
}

/**
* @template T
* @param {() => T} callback
* @returns {T | null}
*/
function parseArguments(callback) {
try {
return callback();
} catch (error) {
if (!isParseArgsError(error)) throw error;

return null;
}
}

/** @param {readonly {kind: string, name?: string}[]} tokens */
function repeatedValueOption(tokens) {
const names = tokens
.filter(
(token) =>
token.kind === "option" &&
typeof token.name === "string" &&
token.name !== "help",
)
.map((token) => token.name);

return new Set(names).size !== names.length;
}

/** @param {unknown} error */
function isParseArgsError(error) {
return (
Expand Down
Loading