@sous-io/sous 0.2.7 → 0.2.9
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 -7
- package/docs/markdown/README.md +2 -2
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +6 -4
- 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.mjs +8 -13
- package/src/lib/project-scaffold/index.ts +84 -3
- package/src/lib/project-scaffold/templates.ts +6 -4
package/README.md
CHANGED
|
@@ -37,8 +37,8 @@ A project install pins the sous version a project builds with, and it always doe
|
|
|
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
39
|
output. When the two versions differ, one line on standard error names the version handed off
|
|
40
|
-
to
|
|
41
|
-
invoked instead.
|
|
40
|
+
to; `--verbose` announces every hand-off and adds where both installs are. Set
|
|
41
|
+
`SOUS_NO_DELEGATE=1` to run the copy you invoked instead.
|
|
42
42
|
|
|
43
43
|
Or run it from a clone (useful when developing sous itself):
|
|
44
44
|
|
|
@@ -58,9 +58,11 @@ sous init
|
|
|
58
58
|
|
|
59
59
|
`sous init` writes the project's `.sous/` directory and runs the first build. It creates a
|
|
60
60
|
commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
|
|
61
|
-
schema), a starter prompt at `.sous/
|
|
61
|
+
schema), a starter prompt at `.sous/memories/AGENTS.md` that the config compiles to `AGENTS.md`
|
|
62
62
|
at the project root, the two env files described below, and a `.sous/.gitignore` covering the
|
|
63
|
-
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
|
|
64
66
|
`core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
|
|
65
67
|
already holds a config is left untouched.
|
|
66
68
|
|
|
@@ -73,7 +75,7 @@ export const config = {
|
|
|
73
75
|
compilation: {
|
|
74
76
|
targets: [
|
|
75
77
|
{
|
|
76
|
-
entryPoint: "${sousDir}/
|
|
78
|
+
entryPoint: "${sousDir}/memories/AGENTS.md",
|
|
77
79
|
outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
|
|
78
80
|
},
|
|
79
81
|
],
|
|
@@ -165,8 +167,9 @@ A recipe's files are reachable without knowing where anything is installed. An `
|
|
|
165
167
|
path may name a recipe by its namespace, so
|
|
166
168
|
`@~workflow/task-files/_partials/resume-task.md` composes a block published by the recipe
|
|
167
169
|
`workflow/task-files`, at the version your project has pinned, into your own instruction
|
|
168
|
-
file. `@~project/...` names your project's root,
|
|
169
|
-
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.
|
|
170
173
|
|
|
171
174
|
Sous records every file and directory it writes in a state file, `.sous/sous.state.json` by
|
|
172
175
|
default, which is what lets `prune` and `clear` clean up precisely instead of guessing.
|
package/docs/markdown/README.md
CHANGED
|
@@ -36,8 +36,8 @@ Sous installs three ways, and they work together:
|
|
|
36
36
|
|
|
37
37
|
When the two versions differ, one line on standard error names the version handed off to;
|
|
38
38
|
standard output is untouched, so a piped command prints exactly what it always did. Add
|
|
39
|
-
`--verbose` to any command
|
|
40
|
-
|
|
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
41
|
`SOUS_NO_DELEGATE` to anything but `0`, `false`, `no` or `off` to run the copy you invoked
|
|
42
42
|
instead, for debugging a broken project install or for deliberately using the global one:
|
|
43
43
|
|
|
@@ -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)
|
|
@@ -50,9 +50,11 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
|
|
|
50
50
|
### `sous init [DIRECTORY]`
|
|
51
51
|
Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
|
|
52
52
|
runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
|
|
53
|
-
primary config, a starter prompt at `.sous/
|
|
54
|
-
project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`.
|
|
55
|
-
|
|
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`.
|
|
56
58
|
|
|
57
59
|
A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
|
|
58
60
|
inside a project that is already set up is a question rather than an error, since a subproject may want its own
|
|
@@ -340,7 +342,7 @@ No expected failure prints a stack trace. A failure sous did not expect prints t
|
|
|
340
342
|
asking you to set `SOUS_DEBUG=1` and run the command again. Set `SOUS_DEBUG` to anything but `0`, `false`, `no`
|
|
341
343
|
or `off` and every reported failure prints its stack to standard error underneath the message:
|
|
342
344
|
`SOUS_DEBUG=1 sous build`. The one other thing it changes is that every hand-off to a project's own install
|
|
343
|
-
announces itself, not only one between different versions.
|
|
345
|
+
announces itself in full, not only one between different versions; `--verbose` does the same.
|
|
344
346
|
|
|
345
347
|
`SOUS_NO_DELEGATE` is the other environment variable every command reads. Set it the same way and the copy of
|
|
346
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
|
*
|
|
@@ -27,11 +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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* keep the invoked one running.
|
|
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.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import fs from "node:fs";
|
|
@@ -167,15 +167,10 @@ export function planHandoff({ cwd, ownRoot, env, argv = [] }) {
|
|
|
167
167
|
|
|
168
168
|
const ownPkg = readPackageJson(ownRoot);
|
|
169
169
|
const ownVersion = typeof ownPkg?.version === "string" ? ownPkg.version : "unknown";
|
|
170
|
-
const
|
|
171
|
-
const announce = ownVersion !== install.version ||
|
|
170
|
+
const verbose = isEnvFlagOn(env[DEBUG_ENV]) || argv.includes(VERBOSE_FLAG);
|
|
171
|
+
const announce = ownVersion !== install.version || verbose;
|
|
172
172
|
const notice = announce
|
|
173
|
-
? formatHandoffNotice({
|
|
174
|
-
install,
|
|
175
|
-
ownVersion,
|
|
176
|
-
ownRoot: realpathOr(ownRoot),
|
|
177
|
-
verbose: debug || argv.includes(VERBOSE_FLAG),
|
|
178
|
-
})
|
|
173
|
+
? formatHandoffNotice({ install, ownVersion, ownRoot: realpathOr(ownRoot), verbose })
|
|
179
174
|
: [];
|
|
180
175
|
return { kind: "hand-off", install, notice };
|
|
181
176
|
}
|
|
@@ -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.
|