@sous-io/sous 0.2.6 → 0.2.8
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/README.md +10 -6
- package/bin/run.js +13 -0
- package/docs/markdown/README.md +19 -8
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -5
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +9 -2
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/init.ts +28 -0
- package/src/lib/include-resolver.ts +52 -9
- package/src/lib/project-install.d.mts +10 -1
- package/src/lib/project-install.mjs +36 -15
- package/src/lib/project-scaffold/index.ts +84 -3
- package/src/lib/project-scaffold/templates.ts +6 -4
- package/src/lib/version-report.ts +90 -0
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ npm install -D @sous-io/sous # the version this project's templates were wri
|
|
|
36
36
|
A project install pins the sous version a project builds with, and it always does the
|
|
37
37
|
building: a global `sous` run anywhere inside a project that holds `node_modules/@sous-io/sous`
|
|
38
38
|
hands the command to that copy, so `sous`, `npx sous` and a package script all produce the same
|
|
39
|
-
output. When the two versions differ, one line on standard error
|
|
39
|
+
output. When the two versions differ, one line on standard error names the version handed off
|
|
40
|
+
to; `--verbose` announces every hand-off and adds where both installs are. Set
|
|
40
41
|
`SOUS_NO_DELEGATE=1` to run the copy you invoked instead.
|
|
41
42
|
|
|
42
43
|
Or run it from a clone (useful when developing sous itself):
|
|
@@ -57,9 +58,11 @@ sous init
|
|
|
57
58
|
|
|
58
59
|
`sous init` writes the project's `.sous/` directory and runs the first build. It creates a
|
|
59
60
|
commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
|
|
60
|
-
schema), a starter prompt at `.sous/
|
|
61
|
+
schema), a starter prompt at `.sous/memories/AGENTS.md` that the config compiles to `AGENTS.md`
|
|
61
62
|
at the project root, the two env files described below, and a `.sous/.gitignore` covering the
|
|
62
|
-
files sous keeps local to one machine.
|
|
63
|
+
files sous keeps local to one machine. A project with a `package.json` also gains `@sous-io/sous`
|
|
64
|
+
as a devDependency at the running version, so the project pins the sous it builds with; run
|
|
65
|
+
your package manager's install afterwards. The first build compiles the starter prompt and the
|
|
63
66
|
`core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
|
|
64
67
|
already holds a config is left untouched.
|
|
65
68
|
|
|
@@ -72,7 +75,7 @@ export const config = {
|
|
|
72
75
|
compilation: {
|
|
73
76
|
targets: [
|
|
74
77
|
{
|
|
75
|
-
entryPoint: "${sousDir}/
|
|
78
|
+
entryPoint: "${sousDir}/memories/AGENTS.md",
|
|
76
79
|
outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
|
|
77
80
|
},
|
|
78
81
|
],
|
|
@@ -164,8 +167,9 @@ A recipe's files are reachable without knowing where anything is installed. An `
|
|
|
164
167
|
path may name a recipe by its namespace, so
|
|
165
168
|
`@~workflow/task-files/_partials/resume-task.md` composes a block published by the recipe
|
|
166
169
|
`workflow/task-files`, at the version your project has pinned, into your own instruction
|
|
167
|
-
file. `@~project/...` names your project's root,
|
|
168
|
-
an `_aliases` block.
|
|
170
|
+
file. `@~project/...` names your project's root, `@~/...` names your home directory, and you
|
|
171
|
+
can define your own aliases with an `_aliases` block. Either spelling of a file finds it:
|
|
172
|
+
`@shared.md` also finds `shared.tpl.md`, and the other way round.
|
|
169
173
|
|
|
170
174
|
Sous records every file and directory it writes in a state file, `.sous/sous.state.json` by
|
|
171
175
|
default, which is what lets `prune` and `clear` clean up precisely instead of guessing.
|
package/bin/run.js
CHANGED
|
@@ -20,6 +20,19 @@ if (!handedOff) {
|
|
|
20
20
|
const { register } = await import("tsx/esm/api");
|
|
21
21
|
register();
|
|
22
22
|
|
|
23
|
+
// `sous --version` is answered here rather than by oclif, whose answer is
|
|
24
|
+
// its user agent string. Plain, it is the version alone; `--verbose` adds
|
|
25
|
+
// the package, where it is installed, the platform and the Node build.
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const { isVersionRequest, printVersion } = await import("../src/lib/version-report.ts");
|
|
28
|
+
if (isVersionRequest(argv)) {
|
|
29
|
+
printVersion(argv, ownRoot);
|
|
30
|
+
} else {
|
|
31
|
+
await runCommand();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runCommand() {
|
|
23
36
|
const { execute, settings } = await import("@oclif/core");
|
|
24
37
|
|
|
25
38
|
// tsx (above) already makes .ts imports work, so oclif's own auto-transpile
|
package/docs/markdown/README.md
CHANGED
|
@@ -34,18 +34,29 @@ Sous installs three ways, and they work together:
|
|
|
34
34
|
directory the way Node resolves a package, so a copy hoisted to a monorepo root is found from
|
|
35
35
|
any package inside it.
|
|
36
36
|
|
|
37
|
-
When the two versions differ, one
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
When the two versions differ, one line on standard error names the version handed off to;
|
|
38
|
+
standard output is untouched, so a piped command prints exactly what it always did. Add
|
|
39
|
+
`--verbose` to any command, or set `SOUS_DEBUG`, and every hand-off is announced, with where
|
|
40
|
+
both installs are and how to keep the invoked one running. Set
|
|
41
|
+
`SOUS_NO_DELEGATE` to anything but `0`, `false`, `no` or `off` to run the copy you invoked
|
|
42
|
+
instead, for debugging a broken project install or for deliberately using the global one:
|
|
42
43
|
|
|
43
44
|
```term
|
|
44
45
|
$ sous --version
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
Handing off to the project-level Sous install: v0.2.4
|
|
47
|
+
v0.2.4
|
|
48
|
+
$ sous --version --verbose
|
|
49
|
+
Handing off to the project-level Sous install: v0.2.4
|
|
50
|
+
Project install: /work/app/node_modules/@sous-io/sous
|
|
51
|
+
Invoked install: v0.3.0 at /usr/lib/node_modules/@sous-io/sous
|
|
52
|
+
Set SOUS_NO_DELEGATE=1 to run the invoked install instead.
|
|
53
|
+
v0.2.4
|
|
54
|
+
Package : @sous-io/sous
|
|
55
|
+
Install : /work/app/node_modules/@sous-io/sous
|
|
56
|
+
Platform: linux-x64
|
|
57
|
+
Node : v22.21.0
|
|
47
58
|
$ SOUS_NO_DELEGATE=1 sous --version
|
|
48
|
-
|
|
59
|
+
v0.3.0
|
|
49
60
|
```
|
|
50
61
|
|
|
51
62
|
## Where to look
|
|
@@ -22,3 +22,4 @@
|
|
|
22
22
|
- [0002: Recipe answers in the template scope](adrs/0002-recipe-answers-in-templates.md)
|
|
23
23
|
- [0003: Project setup with sous init](adrs/0003-project-init.md)
|
|
24
24
|
- [0004: A global sous defers to the project's install](adrs/0004-project-install-handoff.md)
|
|
25
|
+
- [0005: Follow-ups from the first project on the hand-off](adrs/0005-first-project-follow-ups.md)
|
|
@@ -38,7 +38,8 @@ under [When sous cannot ask](repositories-consuming.md#when-sous-cannot-ask).
|
|
|
38
38
|
|
|
39
39
|
Help has four spellings. `sous --help` prints the root screen; `sous repo add --help`, `sous repo add -h` and
|
|
40
40
|
`sous help repo add` all print that one command's. `sous help` alone lists the topics and commands, and
|
|
41
|
-
`sous --version` prints the version,
|
|
41
|
+
`sous --version` prints the version alone, as `v1.2.3`; `sous --version --verbose` adds the package name, where
|
|
42
|
+
it is installed, the platform and the Node build under it.
|
|
42
43
|
|
|
43
44
|
Every topic answers to both spellings of its name: `repo` and `repos`, `subscription` and `subscriptions`,
|
|
44
45
|
`namespace` and `namespaces`, `recipe` and `recipes`, `lock` and `locks`, `vars` and `var`, `config` and
|
|
@@ -49,9 +50,11 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
|
|
|
49
50
|
### `sous init [DIRECTORY]`
|
|
50
51
|
Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
|
|
51
52
|
runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
|
|
52
|
-
primary config, a starter prompt at `.sous/
|
|
53
|
-
project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`.
|
|
54
|
-
|
|
53
|
+
primary config, a starter prompt at `.sous/memories/AGENTS.md` that the config compiles to `AGENTS.md` at the
|
|
54
|
+
project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. A
|
|
55
|
+
project that has a `package.json` also gains `@sous-io/sous` in its `devDependencies`, at exactly the running
|
|
56
|
+
version, unless it already depends on it; nothing is installed, so run your package manager's install afterwards.
|
|
57
|
+
The first build compiles the starter prompt and the `core` skills, and pins them in `.sous/sous.lock.json`.
|
|
55
58
|
|
|
56
59
|
A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
|
|
57
60
|
inside a project that is already set up is a question rather than an error, since a subproject may want its own
|
|
@@ -339,7 +342,7 @@ No expected failure prints a stack trace. A failure sous did not expect prints t
|
|
|
339
342
|
asking you to set `SOUS_DEBUG=1` and run the command again. Set `SOUS_DEBUG` to anything but `0`, `false`, `no`
|
|
340
343
|
or `off` and every reported failure prints its stack to standard error underneath the message:
|
|
341
344
|
`SOUS_DEBUG=1 sous build`. The one other thing it changes is that every hand-off to a project's own install
|
|
342
|
-
announces itself, not only one between different versions.
|
|
345
|
+
announces itself in full, not only one between different versions; `--verbose` does the same.
|
|
343
346
|
|
|
344
347
|
`SOUS_NO_DELEGATE` is the other environment variable every command reads. Set it the same way and the copy of
|
|
345
348
|
sous you invoked runs the command, even inside a project that installs its own `@sous-io/sous`; see
|
package/package.json
CHANGED
|
@@ -93,8 +93,8 @@ Include another file at render time (path **relative to the template file's dire
|
|
|
93
93
|
|
|
94
94
|
`render` resolves paths relative to the template file. For files outside that tree, use
|
|
95
95
|
a recipe reference (`@~<namespace>/<recipe>/...`), a path **alias** (`@~project/...`, or a
|
|
96
|
-
user-defined alias) or a `@`-prefixed `${var}` path; the
|
|
97
|
-
(see below) works in `render` too.
|
|
96
|
+
user-defined alias), a home-relative path (`@~/...`) or a `@`-prefixed `${var}` path; the
|
|
97
|
+
same resolution as `@include` (see below) works in `render` too.
|
|
98
98
|
|
|
99
99
|
To prevent template sequences from being processed in a code example, wrap the block in
|
|
100
100
|
`raw` / `endraw` tag blocks. These blocks cannot be nested: the first `endraw`
|
|
@@ -156,6 +156,13 @@ namespaces. There is exactly one:
|
|
|
156
156
|
|
|
157
157
|
- `@~project/...` → the consuming project's root.
|
|
158
158
|
|
|
159
|
+
A bare `@~/...` is not an alias: the sigil followed only by a separator is the home
|
|
160
|
+
directory, so `@~/notes/context.md` includes a file from your home directory.
|
|
161
|
+
|
|
162
|
+
An include finds a file by either spelling. `@shared.md` finds `shared.tpl.md` when that is
|
|
163
|
+
what exists, and `@notes.tpl.md` finds `notes.md`; the spelling written is tried first. So a
|
|
164
|
+
file can be turned into a template, or back, without touching the lines that include it.
|
|
165
|
+
|
|
159
166
|
Everything else sous once shipped inside its own package is published as a recipe now, so
|
|
160
167
|
a recipe reference is what reaches it.
|
|
161
168
|
|
package/src/commands/init.ts
CHANGED
|
@@ -31,10 +31,12 @@ import { nonInteractiveError } from "../lib/interactive.js";
|
|
|
31
31
|
import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
|
|
32
32
|
import {
|
|
33
33
|
PROJECT_CONFIG_FORMATS,
|
|
34
|
+
SOUS_PACKAGE_NAME,
|
|
34
35
|
STARTER_OUTPUT_NAME,
|
|
35
36
|
STARTER_PROMPT_RELATIVE_PATH,
|
|
36
37
|
scaffoldProject,
|
|
37
38
|
sousDirFor,
|
|
39
|
+
type PackageJsonOutcome,
|
|
38
40
|
type ProjectConfigFormat,
|
|
39
41
|
} from "../lib/project-scaffold/index.js";
|
|
40
42
|
import { SOUS_VERSION } from "../lib/settings.js";
|
|
@@ -50,8 +52,28 @@ import {
|
|
|
50
52
|
showCommandVars,
|
|
51
53
|
showVariables,
|
|
52
54
|
warning,
|
|
55
|
+
type VariableEntry,
|
|
53
56
|
} from "../utils/formatting.js";
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The "Dependency" row of the summary: where sous now sits in the project's
|
|
60
|
+
* package.json, or nothing when the project has no package.json.
|
|
61
|
+
*/
|
|
62
|
+
function dependencyRow(outcome: PackageJsonOutcome): VariableEntry[] {
|
|
63
|
+
if (outcome.kind === "absent") return [];
|
|
64
|
+
const value = `${SOUS_PACKAGE_NAME} ${outcome.range}`;
|
|
65
|
+
if (outcome.kind === "present") {
|
|
66
|
+
return [{ label: "Dependency", value, detail: `already in ${outcome.section} in package.json` }];
|
|
67
|
+
}
|
|
68
|
+
return [
|
|
69
|
+
{
|
|
70
|
+
label: "Dependency",
|
|
71
|
+
value,
|
|
72
|
+
detail: "added to devDependencies in package.json; run your package manager's install to fetch it",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
export default class Init extends BaseCommand {
|
|
56
78
|
static description =
|
|
57
79
|
"Set a project up for sous: write its .sous/ directory, then run the first build";
|
|
@@ -129,6 +151,11 @@ export default class Init extends BaseCommand {
|
|
|
129
151
|
if (result.dryRun) dryRunNotice(`would write ${file}`);
|
|
130
152
|
else log(` wrote ${file}`);
|
|
131
153
|
}
|
|
154
|
+
if (result.packageJson.kind === "added") {
|
|
155
|
+
const what = `${SOUS_PACKAGE_NAME} ${result.packageJson.range} to devDependencies in package.json`;
|
|
156
|
+
if (result.dryRun) dryRunNotice(`would add ${what}`);
|
|
157
|
+
else log(` added ${what}`);
|
|
158
|
+
}
|
|
132
159
|
|
|
133
160
|
if (result.dryRun) {
|
|
134
161
|
blankLine();
|
|
@@ -147,6 +174,7 @@ export default class Init extends BaseCommand {
|
|
|
147
174
|
{ label: "Prompt source", value: path.join(sousDir, STARTER_PROMPT_RELATIVE_PATH) },
|
|
148
175
|
{ label: "Compiled to", value: path.join(projectRoot, STARTER_OUTPUT_NAME) },
|
|
149
176
|
{ label: "Skills", value: path.join(projectRoot, ".claude", "skills") },
|
|
177
|
+
...dependencyRow(result.packageJson),
|
|
150
178
|
{ label: "Shared answers", value: path.join(sousDir, ".env"), detail: "committed" },
|
|
151
179
|
{
|
|
152
180
|
label: "Local answers",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { expandHome } from "./config-discovery.js";
|
|
2
3
|
import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-resolver.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -10,8 +11,11 @@ import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-r
|
|
|
10
11
|
* that exists on disk; if none exist, it errors listing every candidate tried.
|
|
11
12
|
*
|
|
12
13
|
* Resolution pipeline for a raw path P (with leading `@` already stripped):
|
|
13
|
-
* 1. Substitute ${vars} in P
|
|
14
|
-
*
|
|
14
|
+
* 1. Substitute ${vars} in P, then expand a leading `~/` to the home
|
|
15
|
+
* directory (the `~` sigil on its own, with nothing but a separator after
|
|
16
|
+
* it, names no alias or namespace, so `@~/notes/x.md` is unambiguous). If
|
|
17
|
+
* the result is absolute, it is the sole candidate (feature:
|
|
18
|
+
* `@${sousRootPath}/x.md`).
|
|
15
19
|
* 2. Split the first segment (up to the first `/` or `:`) as the alias key,
|
|
16
20
|
* the remainder as `rest`. If the key is a registered alias, push
|
|
17
21
|
* join(base, rest) for EACH base in the alias's ordered array.
|
|
@@ -19,10 +23,16 @@ import type { NamespaceResolution, NamespaceResolver } from "./repos/namespace-r
|
|
|
19
23
|
* for the recipe namespace named by the key (minus the `~`) and push its
|
|
20
24
|
* candidates. Aliases are consulted first, so `~project` keeps meaning
|
|
21
25
|
* the built-in alias even if a namespace of that name exists.
|
|
22
|
-
* 4. Always push the relative candidate: join(baseDir, P)
|
|
26
|
+
* 4. Always push the relative candidate: join(baseDir, P), the FULL path
|
|
23
27
|
* including the alias segment. This lets an alias augment a real relative
|
|
24
28
|
* directory of the same name (e.g. `@stuff/x` tries the alias bases, then
|
|
25
29
|
* `./stuff/x`).
|
|
30
|
+
* 5. Follow every candidate with its `.tpl.` twin: `x.md` is followed by
|
|
31
|
+
* `x.tpl.md`, and `x.tpl.md` by `x.md`. The literal spelling is always
|
|
32
|
+
* tried first, and the twin comes right after it (not after every other
|
|
33
|
+
* candidate), so an alias base still beats the relative fallback. A
|
|
34
|
+
* writer therefore never has to know whether an included file has been
|
|
35
|
+
* turned into a template or back.
|
|
26
36
|
*
|
|
27
37
|
* A key WITHOUT the `~` sigil never reaches the namespace resolver: a bare
|
|
28
38
|
* `@path` is always a relative path or a declared alias, so include lines never
|
|
@@ -48,6 +58,39 @@ export function substituteVars(str: string, scope: Record<string, string>): stri
|
|
|
48
58
|
return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** The marker that makes a file a template, always right before the final extension. */
|
|
62
|
+
const TEMPLATE_MARKER = ".tpl";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `.tpl.` twin of a path: `x.md` gives `x.tpl.md`, `x.tpl.md` gives `x.md`,
|
|
66
|
+
* and a path with no extension has no twin.
|
|
67
|
+
*
|
|
68
|
+
* @param filePath - Any path, absolute or not.
|
|
69
|
+
* @returns The twin, or undefined when there is none.
|
|
70
|
+
*/
|
|
71
|
+
export function templateTwin(filePath: string): string | undefined {
|
|
72
|
+
const dir = path.dirname(filePath);
|
|
73
|
+
const base = path.basename(filePath);
|
|
74
|
+
const ext = path.extname(base);
|
|
75
|
+
if (ext === "" || ext === base) return undefined;
|
|
76
|
+
const stem = base.slice(0, -ext.length);
|
|
77
|
+
const twin = stem.endsWith(TEMPLATE_MARKER)
|
|
78
|
+
? `${stem.slice(0, -TEMPLATE_MARKER.length)}${ext}`
|
|
79
|
+
: `${stem}${TEMPLATE_MARKER}${ext}`;
|
|
80
|
+
return path.join(dir, twin);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every candidate followed by its `.tpl.` twin, de-duplicated, order kept. */
|
|
84
|
+
function withTemplateTwins(candidates: readonly string[]): string[] {
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
for (const candidate of candidates) {
|
|
87
|
+
out.push(candidate);
|
|
88
|
+
const twin = templateTwin(candidate);
|
|
89
|
+
if (twin !== undefined) out.push(twin);
|
|
90
|
+
}
|
|
91
|
+
return [...new Set(out)];
|
|
92
|
+
}
|
|
93
|
+
|
|
51
94
|
/**
|
|
52
95
|
* Split an include path into its leading alias key and the remainder. The key
|
|
53
96
|
* is the run of characters up to the first `/` or `:` separator.
|
|
@@ -115,11 +158,11 @@ export type IncludeResolution = {
|
|
|
115
158
|
export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): IncludeResolution {
|
|
116
159
|
const aliases = opts.aliases ?? {};
|
|
117
160
|
const scope = opts.scope ?? {};
|
|
118
|
-
const substituted = substituteVars(rawPath, scope);
|
|
161
|
+
const substituted = expandHome(substituteVars(rawPath, scope));
|
|
119
162
|
|
|
120
|
-
// 1. Substituted to an absolute path → that
|
|
163
|
+
// 1. Substituted (or home-expanded) to an absolute path → that, and its twin.
|
|
121
164
|
if (path.isAbsolute(substituted)) {
|
|
122
|
-
return { candidates: [path.normalize(substituted)] };
|
|
165
|
+
return { candidates: withTemplateTwins([path.normalize(substituted)]) };
|
|
123
166
|
}
|
|
124
167
|
|
|
125
168
|
const candidates: string[] = [];
|
|
@@ -151,8 +194,8 @@ export function resolveInclude(rawPath: string, opts: IncludeResolveOptions): In
|
|
|
151
194
|
// 4. Relative fallback: the FULL substituted path under the including dir.
|
|
152
195
|
candidates.push(path.resolve(opts.baseDir, substituted));
|
|
153
196
|
|
|
154
|
-
//
|
|
155
|
-
return { candidates:
|
|
197
|
+
// 5. Each candidate's `.tpl.` twin right after it; de-duped, order kept.
|
|
198
|
+
return { candidates: withTemplateTwins(candidates), namespaceIssue };
|
|
156
199
|
}
|
|
157
200
|
|
|
158
201
|
/**
|
|
@@ -169,7 +212,7 @@ export function resolveIncludeCandidates(rawPath: string, opts: IncludeResolveOp
|
|
|
169
212
|
/**
|
|
170
213
|
* Expand a leading alias segment in a path or glob pattern to one candidate
|
|
171
214
|
* per alias base, in the alias's base order. Used for config entry paths
|
|
172
|
-
* (`entryGlob`/watch patterns), where
|
|
215
|
+
* (`entryGlob`/watch patterns), where, unlike @include resolution, there is
|
|
173
216
|
* no including file to supply a relative fallback, so a non-alias path is
|
|
174
217
|
* returned unchanged as the sole candidate.
|
|
175
218
|
*
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
export const PACKAGE_NAME: "@sous-io/sous";
|
|
7
7
|
export const NO_DELEGATE_ENV: "SOUS_NO_DELEGATE";
|
|
8
8
|
export const DEBUG_ENV: "SOUS_DEBUG";
|
|
9
|
+
export const VERBOSE_FLAG: "--verbose";
|
|
9
10
|
|
|
10
11
|
export type ProjectInstall =
|
|
11
12
|
| { same: true; root: string }
|
|
@@ -13,19 +14,27 @@ export type ProjectInstall =
|
|
|
13
14
|
|
|
14
15
|
export type HandoffPlan =
|
|
15
16
|
| { kind: "run-self" }
|
|
16
|
-
| { kind: "hand-off"; install: Extract<ProjectInstall, { same: false }>; notice: string
|
|
17
|
+
| { kind: "hand-off"; install: Extract<ProjectInstall, { same: false }>; notice: string[] };
|
|
17
18
|
|
|
18
19
|
export function isEnvFlagOn(value: string | undefined): boolean;
|
|
19
20
|
export function binEntryOf(pkg: unknown): string | undefined;
|
|
20
21
|
export function findProjectInstall(startDir: string, ownRoot: string): ProjectInstall | undefined;
|
|
22
|
+
export function formatHandoffNotice(input: {
|
|
23
|
+
install: Extract<ProjectInstall, { same: false }>;
|
|
24
|
+
ownVersion: string;
|
|
25
|
+
ownRoot: string;
|
|
26
|
+
verbose: boolean;
|
|
27
|
+
}): string[];
|
|
21
28
|
export function planHandoff(input: {
|
|
22
29
|
cwd: string;
|
|
23
30
|
ownRoot: string;
|
|
24
31
|
env: Record<string, string | undefined>;
|
|
32
|
+
argv?: readonly string[];
|
|
25
33
|
}): HandoffPlan;
|
|
26
34
|
export function handOffToProjectInstall(input: {
|
|
27
35
|
ownRoot: string;
|
|
28
36
|
cwd?: string;
|
|
29
37
|
env?: Record<string, string | undefined>;
|
|
38
|
+
argv?: readonly string[];
|
|
30
39
|
stderr?: { write(chunk: string): unknown };
|
|
31
40
|
}): Promise<boolean>;
|
|
@@ -27,8 +27,11 @@
|
|
|
27
27
|
* - Anything unreadable or ambiguous means "run the copy that was invoked".
|
|
28
28
|
* A hand-off is a convenience; a refusal to run is not.
|
|
29
29
|
* - `SOUS_NO_DELEGATE` (anything but 0/false/no/off) runs the invoked copy.
|
|
30
|
-
* - The notice goes to stderr, so piped stdout stays clean
|
|
31
|
-
* the two versions differ
|
|
30
|
+
* - The notice goes to stderr, so piped stdout stays clean. It prints when
|
|
31
|
+
* the two versions differ, and on every hand-off under `SOUS_DEBUG` or
|
|
32
|
+
* with `--verbose` anywhere on the command line. Plain, it is one line
|
|
33
|
+
* naming the version handed off to; verbose (`--verbose` or `SOUS_DEBUG`)
|
|
34
|
+
* it adds where both installs are and how to keep the invoked one running.
|
|
32
35
|
*/
|
|
33
36
|
|
|
34
37
|
import fs from "node:fs";
|
|
@@ -44,6 +47,9 @@ export const NO_DELEGATE_ENV = "SOUS_NO_DELEGATE";
|
|
|
44
47
|
/** The environment variable that makes every hand-off announce itself. */
|
|
45
48
|
export const DEBUG_ENV = "SOUS_DEBUG";
|
|
46
49
|
|
|
50
|
+
/** The flag that makes the notice say where both installs are. */
|
|
51
|
+
export const VERBOSE_FLAG = "--verbose";
|
|
52
|
+
|
|
47
53
|
/**
|
|
48
54
|
* Whether an on/off environment variable is on: set to anything but an empty
|
|
49
55
|
* string, `0`, `false`, `no` or `off` (case-insensitive, whitespace trimmed).
|
|
@@ -129,29 +135,43 @@ function describeInstall(candidate, ownRoot) {
|
|
|
129
135
|
return { same: false, root, version: typeof pkg.version === "string" ? pkg.version : "unknown", bin };
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The lines the notice is made of: one line naming the version handed off to,
|
|
140
|
+
* and, when verbose, where both installs are and how to keep the invoked one
|
|
141
|
+
* running. Plain text, because this prints before tsx exists; the block
|
|
142
|
+
* mirrors the shape `showVariables` gives a key and value list.
|
|
143
|
+
*/
|
|
144
|
+
export function formatHandoffNotice({ install, ownVersion, ownRoot, verbose }) {
|
|
145
|
+
const lines = [`Handing off to the project-level Sous install: v${install.version}`];
|
|
146
|
+
if (verbose) {
|
|
147
|
+
lines.push(
|
|
148
|
+
` Project install: ${install.root}`,
|
|
149
|
+
` Invoked install: v${ownVersion} at ${ownRoot}`,
|
|
150
|
+
`Set ${NO_DELEGATE_ENV}=1 to run the invoked install instead.`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
/**
|
|
133
157
|
* Decides what the invoked install should do, without doing it.
|
|
134
158
|
*
|
|
135
159
|
* Returns `{ kind: "run-self" }` when the invoked copy runs the command, or
|
|
136
160
|
* `{ kind: "hand-off", install, notice }` naming the project copy to import
|
|
137
|
-
* and the
|
|
161
|
+
* and the lines to print on stderr first (an empty list when nothing is said).
|
|
138
162
|
*/
|
|
139
|
-
export function planHandoff({ cwd, ownRoot, env }) {
|
|
163
|
+
export function planHandoff({ cwd, ownRoot, env, argv = [] }) {
|
|
140
164
|
if (isEnvFlagOn(env[NO_DELEGATE_ENV])) return { kind: "run-self" };
|
|
141
165
|
const install = findProjectInstall(cwd, ownRoot);
|
|
142
166
|
if (!install || install.same) return { kind: "run-self" };
|
|
143
167
|
|
|
144
168
|
const ownPkg = readPackageJson(ownRoot);
|
|
145
169
|
const ownVersion = typeof ownPkg?.version === "string" ? ownPkg.version : "unknown";
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`sous ${ownVersion} you invoked; set ${NO_DELEGATE_ENV}=1 to run the one you invoked.`;
|
|
152
|
-
} else if (isEnvFlagOn(env[DEBUG_ENV])) {
|
|
153
|
-
notice = `Running the project's own sous ${install.version} from ${install.root}.`;
|
|
154
|
-
}
|
|
170
|
+
const verbose = isEnvFlagOn(env[DEBUG_ENV]) || argv.includes(VERBOSE_FLAG);
|
|
171
|
+
const announce = ownVersion !== install.version || verbose;
|
|
172
|
+
const notice = announce
|
|
173
|
+
? formatHandoffNotice({ install, ownVersion, ownRoot: realpathOr(ownRoot), verbose })
|
|
174
|
+
: [];
|
|
155
175
|
return { kind: "hand-off", install, notice };
|
|
156
176
|
}
|
|
157
177
|
|
|
@@ -165,11 +185,12 @@ export async function handOffToProjectInstall({
|
|
|
165
185
|
ownRoot,
|
|
166
186
|
cwd = process.cwd(),
|
|
167
187
|
env = process.env,
|
|
188
|
+
argv = process.argv.slice(2),
|
|
168
189
|
stderr = process.stderr,
|
|
169
190
|
}) {
|
|
170
|
-
const plan = planHandoff({ cwd, ownRoot, env });
|
|
191
|
+
const plan = planHandoff({ cwd, ownRoot, env, argv });
|
|
171
192
|
if (plan.kind !== "hand-off") return false;
|
|
172
|
-
|
|
193
|
+
for (const line of plan.notice) stderr.write(`${line}\n`);
|
|
173
194
|
await import(pathToFileURL(plan.install.bin).href);
|
|
174
195
|
return true;
|
|
175
196
|
}
|
|
@@ -8,10 +8,16 @@
|
|
|
8
8
|
*
|
|
9
9
|
* It refuses to touch a project that is already set up: a `.sous/` directory
|
|
10
10
|
* holding a primary config is left exactly as it is, and so is any file the
|
|
11
|
-
* scaffold would otherwise write.
|
|
12
|
-
*
|
|
11
|
+
* scaffold would otherwise write. Two files are merged rather than replaced:
|
|
12
|
+
* `.sous/.gitignore`, whose sous-managed block is applied by the same writer
|
|
13
13
|
* `sous repo link` uses, so running the scaffold over an existing ignore file
|
|
14
|
-
* never duplicates an entry.
|
|
14
|
+
* never duplicates an entry; and the project's `package.json`, when there is
|
|
15
|
+
* one, which gains `@sous-io/sous` as a devDependency at the running version
|
|
16
|
+
* unless it already depends on it. A project that installs sous pins the
|
|
17
|
+
* version its templates were written against, and a global sous hands off to
|
|
18
|
+
* that copy, so the dependency is what makes every build of the project use
|
|
19
|
+
* one version. Nothing is installed: the project's own package manager does
|
|
20
|
+
* that, and sous does not guess which one it is.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import fs from "node:fs";
|
|
@@ -41,6 +47,15 @@ import {
|
|
|
41
47
|
|
|
42
48
|
export * from "./templates.js";
|
|
43
49
|
|
|
50
|
+
/** The npm package a scaffolded project is made to depend on. */
|
|
51
|
+
export const SOUS_PACKAGE_NAME = "@sous-io/sous";
|
|
52
|
+
|
|
53
|
+
/** What the scaffold did about the project's package.json. */
|
|
54
|
+
export type PackageJsonOutcome =
|
|
55
|
+
| { kind: "absent" }
|
|
56
|
+
| { kind: "present"; path: string; range: string; section: "dependencies" | "devDependencies" }
|
|
57
|
+
| { kind: "added"; path: string; range: string };
|
|
58
|
+
|
|
44
59
|
/** What to scaffold, and where. */
|
|
45
60
|
export type ProjectScaffoldOptions = {
|
|
46
61
|
/**
|
|
@@ -72,6 +87,8 @@ export type ProjectScaffoldResult = {
|
|
|
72
87
|
name: string;
|
|
73
88
|
/** Paths of every file written, relative to the project root, in the order written. */
|
|
74
89
|
files: string[];
|
|
90
|
+
/** Whether `@sous-io/sous` was added to the project's package.json, was already there, or there is no package.json. */
|
|
91
|
+
packageJson: PackageJsonOutcome;
|
|
75
92
|
/** True when nothing was actually written. */
|
|
76
93
|
dryRun: boolean;
|
|
77
94
|
};
|
|
@@ -124,6 +141,8 @@ export async function scaffoldProject(
|
|
|
124
141
|
|
|
125
142
|
const context: ProjectScaffoldContext = { name, sousVersion: options.sousVersion };
|
|
126
143
|
const files = planFiles(sousDir, projectRoot, format, context);
|
|
144
|
+
const dependency = planPackageJson(projectRoot, options.sousVersion);
|
|
145
|
+
if (dependency.file !== undefined) files.push(dependency.file);
|
|
127
146
|
|
|
128
147
|
assertNothingWouldBeOverwritten(projectRoot, files);
|
|
129
148
|
|
|
@@ -145,10 +164,72 @@ export async function scaffoldProject(
|
|
|
145
164
|
format,
|
|
146
165
|
name,
|
|
147
166
|
files: files.map((file) => file.relativePath),
|
|
167
|
+
packageJson: dependency.outcome,
|
|
148
168
|
dryRun,
|
|
149
169
|
};
|
|
150
170
|
}
|
|
151
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Works out the package.json edit: nothing when the project has no
|
|
174
|
+
* package.json or already depends on sous, otherwise the file rewritten with
|
|
175
|
+
* `@sous-io/sous` in `devDependencies` at exactly the running version. The
|
|
176
|
+
* file's own indentation and trailing newline are kept, and `devDependencies`
|
|
177
|
+
* stays sorted the way npm keeps it. A package.json that is not JSON is
|
|
178
|
+
* refused by name, before anything is written.
|
|
179
|
+
*/
|
|
180
|
+
function planPackageJson(
|
|
181
|
+
projectRoot: string,
|
|
182
|
+
sousVersion: string
|
|
183
|
+
): { file?: PlannedFile; outcome: PackageJsonOutcome } {
|
|
184
|
+
const packageJsonPath = path.join(projectRoot, "package.json");
|
|
185
|
+
if (!fs.existsSync(packageJsonPath)) return { outcome: { kind: "absent" } };
|
|
186
|
+
|
|
187
|
+
const raw = fs.readFileSync(packageJsonPath, "utf8");
|
|
188
|
+
let parsed: unknown;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(raw);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw new ConfigError(
|
|
193
|
+
`'sous init' would add ${SOUS_PACKAGE_NAME} to ${packageJsonPath}, but could not read it as JSON.\n` +
|
|
194
|
+
` ${(error as Error).message}\n` +
|
|
195
|
+
` Fix the file, or run 'sous init' in a directory without a package.json.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
199
|
+
throw new ConfigError(
|
|
200
|
+
`'sous init' would add ${SOUS_PACKAGE_NAME} to ${packageJsonPath}, but it does not hold a JSON object.`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const pkg = parsed as Record<string, unknown>;
|
|
204
|
+
|
|
205
|
+
for (const section of ["dependencies", "devDependencies"] as const) {
|
|
206
|
+
const deps = pkg[section];
|
|
207
|
+
if (typeof deps === "object" && deps !== null && SOUS_PACKAGE_NAME in deps) {
|
|
208
|
+
const range = String((deps as Record<string, unknown>)[SOUS_PACKAGE_NAME]);
|
|
209
|
+
return { outcome: { kind: "present", path: packageJsonPath, range, section } };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const existing =
|
|
214
|
+
typeof pkg.devDependencies === "object" && pkg.devDependencies !== null
|
|
215
|
+
? (pkg.devDependencies as Record<string, unknown>)
|
|
216
|
+
: {};
|
|
217
|
+
const devDependencies = Object.fromEntries(
|
|
218
|
+
Object.entries({ ...existing, [SOUS_PACKAGE_NAME]: sousVersion }).sort(([a], [b]) =>
|
|
219
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
|
|
224
|
+
const newline = raw.endsWith("\n") ? "\n" : "";
|
|
225
|
+
const contents = JSON.stringify({ ...pkg, devDependencies }, null, indent) + newline;
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
file: { relativePath: "package.json", contents, merged: true },
|
|
229
|
+
outcome: { kind: "added", path: packageJsonPath, range: sousVersion },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
152
233
|
/** Builds every file the scaffold writes, in the order they are written. */
|
|
153
234
|
function planFiles(
|
|
154
235
|
sousDir: string,
|
|
@@ -28,10 +28,12 @@ export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* The path, relative to `.sous/`, of the starter prompt the scaffolded config
|
|
31
|
-
* compiles. It lives under `
|
|
32
|
-
*
|
|
31
|
+
* compiles. It lives under `memories/`, the directory for what a build composes
|
|
32
|
+
* into an agent's always-loaded instruction file, so the source and its
|
|
33
|
+
* compiled output (`AGENTS.md` at the project root) are never mistaken for one
|
|
34
|
+
* another.
|
|
33
35
|
*/
|
|
34
|
-
export const STARTER_PROMPT_RELATIVE_PATH = "
|
|
36
|
+
export const STARTER_PROMPT_RELATIVE_PATH = "memories/AGENTS.md";
|
|
35
37
|
|
|
36
38
|
/** The name of the file the starter prompt is compiled into, at the project root. */
|
|
37
39
|
export const STARTER_OUTPUT_NAME = "AGENTS.md";
|
|
@@ -152,7 +154,7 @@ source, run \`sous build\`, and the compiled copy follows; never edit the compil
|
|
|
152
154
|
## Working in this project
|
|
153
155
|
|
|
154
156
|
- Describe the project here: what it is, how it is built, and how it is tested.
|
|
155
|
-
- Split long sections into their own files under \`${SOUS_DIR_NAME}/
|
|
157
|
+
- Split long sections into their own files under \`${SOUS_DIR_NAME}/memories/\` and pull
|
|
156
158
|
each one in with a line holding only \`@sections/name.md\`.
|
|
157
159
|
- The skills under \`.claude/skills\` are written by sous from the recipes this
|
|
158
160
|
project subscribes to. \`sous recipe list\` shows what is available.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `sous --version` prints.
|
|
3
|
+
*
|
|
4
|
+
* oclif answers `--version` with its user agent string (package, version,
|
|
5
|
+
* platform and Node build on one line). Sous answers it itself, in bin/run.js,
|
|
6
|
+
* before oclif is loaded: the version alone, or with `--verbose` the same facts
|
|
7
|
+
* as a key and value list, so a person asking "which sous is this?" gets one
|
|
8
|
+
* short line and a person asking "where is it?" gets the rest.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { log, showVariables } from "../utils/formatting.js";
|
|
14
|
+
|
|
15
|
+
/** The one flag oclif treats as a version request, and so does sous. */
|
|
16
|
+
export const VERSION_FLAG = "--version";
|
|
17
|
+
|
|
18
|
+
/** The flag that adds where the install is, the platform and the Node build. */
|
|
19
|
+
export const VERBOSE_FLAG = "--verbose";
|
|
20
|
+
|
|
21
|
+
/** The facts a version report is made of. */
|
|
22
|
+
export type VersionFacts = {
|
|
23
|
+
/** The npm package name. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** The package version. */
|
|
26
|
+
version: string;
|
|
27
|
+
/** The absolute path of the install answering. */
|
|
28
|
+
root: string;
|
|
29
|
+
/** The platform and architecture, the way oclif spells them. */
|
|
30
|
+
platform: string;
|
|
31
|
+
/** The Node build, with its leading `v`. */
|
|
32
|
+
node: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Whether the command line is a version request: `--version` as its first word. */
|
|
36
|
+
export function isVersionRequest(argv: readonly string[]): boolean {
|
|
37
|
+
return argv[0] === VERSION_FLAG;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reads the facts about the install rooted at `packageRoot`. */
|
|
41
|
+
export function readVersionFacts(packageRoot: string): VersionFacts {
|
|
42
|
+
const pkg = JSON.parse(fs.readFileSync(`${packageRoot}/package.json`, "utf8")) as {
|
|
43
|
+
name?: unknown;
|
|
44
|
+
version?: unknown;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
name: typeof pkg.name === "string" ? pkg.name : "unknown",
|
|
48
|
+
version: typeof pkg.version === "string" ? pkg.version : "unknown",
|
|
49
|
+
root: packageRoot,
|
|
50
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
51
|
+
node: process.version,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Prints the report: the version on its own line, and with `verbose` the
|
|
57
|
+
* facts under it as a key and value list.
|
|
58
|
+
*
|
|
59
|
+
* @param facts - What to report.
|
|
60
|
+
* @param verbose - Whether to print the facts under the version.
|
|
61
|
+
* @param write - Where each line goes; standard output by default.
|
|
62
|
+
*/
|
|
63
|
+
export function printVersionReport(
|
|
64
|
+
facts: VersionFacts,
|
|
65
|
+
verbose: boolean,
|
|
66
|
+
write: (line: string) => void = log
|
|
67
|
+
): void {
|
|
68
|
+
write(`v${facts.version}`);
|
|
69
|
+
if (!verbose) return;
|
|
70
|
+
showVariables(
|
|
71
|
+
{
|
|
72
|
+
Package: facts.name,
|
|
73
|
+
Install: facts.root,
|
|
74
|
+
Platform: facts.platform,
|
|
75
|
+
Node: facts.node,
|
|
76
|
+
},
|
|
77
|
+
{ write }
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Answers a version request from the command line: `sous --version`, or
|
|
83
|
+
* `sous --version --verbose`.
|
|
84
|
+
*
|
|
85
|
+
* @param argv - The command line after the program name.
|
|
86
|
+
* @param packageRoot - The root of the install answering.
|
|
87
|
+
*/
|
|
88
|
+
export function printVersion(argv: readonly string[], packageRoot: string): void {
|
|
89
|
+
printVersionReport(readVersionFacts(packageRoot), argv.includes(VERBOSE_FLAG));
|
|
90
|
+
}
|