@clossys/launcher 0.1.2 → 0.3.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/CHANGELOG.md +87 -0
- package/README.md +231 -19
- package/contracts/conversation-contract.md +40 -0
- package/dist/apply-plan-cli.d.ts +7 -0
- package/dist/apply-plan-cli.d.ts.map +1 -0
- package/dist/apply-plan-cli.js +96 -0
- package/dist/apply-plan-cli.js.map +1 -0
- package/dist/apply-plan.d.ts +79 -0
- package/dist/apply-plan.d.ts.map +1 -0
- package/dist/apply-plan.js +129 -0
- package/dist/apply-plan.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +7 -0
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +43 -14
- package/dist/cli.js.map +1 -1
- package/dist/contract.d.ts +28 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +78 -0
- package/dist/contract.js.map +1 -0
- package/dist/core.d.ts +50 -8
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +458 -40
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +4 -0
- package/dist/doctor-cli.d.ts.map +1 -0
- package/dist/doctor-cli.js +32 -0
- package/dist/doctor-cli.js.map +1 -0
- package/dist/doctor.d.ts +28 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +68 -0
- package/dist/doctor.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +19 -1
- package/dist/host.js.map +1 -1
- package/dist/hosts.d.ts +14 -0
- package/dist/hosts.d.ts.map +1 -0
- package/dist/hosts.js +61 -0
- package/dist/hosts.js.map +1 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +20 -0
- package/dist/inventory-adoption.d.ts.map +1 -0
- package/dist/inventory-adoption.js +67 -0
- package/dist/inventory-adoption.js.map +1 -0
- package/dist/manifest.d.ts +20 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +106 -0
- package/dist/manifest.js.map +1 -0
- package/dist/model-profile.d.ts +46 -0
- package/dist/model-profile.d.ts.map +1 -0
- package/dist/model-profile.js +98 -0
- package/dist/model-profile.js.map +1 -0
- package/dist/product-repository.d.ts +26 -0
- package/dist/product-repository.d.ts.map +1 -0
- package/dist/product-repository.js +49 -0
- package/dist/product-repository.js.map +1 -0
- package/dist/skills.d.ts +39 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +197 -0
- package/dist/skills.js.map +1 -0
- package/dist/types.d.ts +78 -1
- package/dist/types.d.ts.map +1 -1
- package/model-profiles/claude-code.json +10 -0
- package/model-profiles/codex.json +10 -0
- package/model-profiles/cursor.json +10 -0
- package/package.json +11 -5
- package/skeleton/README.md +15 -20
- package/skeleton/package.json +1 -1
- package/skill/SKILL.md +53 -0
- package/skill-catalogue/advisor/SKILL.md +52 -0
- package/skill-catalogue/architect/SKILL.md +42 -0
- package/skill-catalogue/bouncer/SKILL.md +43 -0
- package/skill-catalogue/builder/SKILL.md +42 -0
- package/skill-catalogue/butler/SKILL.md +43 -0
- package/skill-catalogue/controller/SKILL.md +50 -0
- package/skill-catalogue/customer/SKILL.md +92 -0
- package/skill-catalogue/designer/SKILL.md +66 -0
- package/skill-catalogue/giver/SKILL.md +43 -0
- package/skill-catalogue/influencer/SKILL.md +43 -0
- package/skill-catalogue/inspector/SKILL.md +42 -0
- package/skill-catalogue/integrator/SKILL.md +42 -0
- package/skill-catalogue/keeper/SKILL.md +43 -0
- package/skill-catalogue/launcher/SKILL.md +53 -0
- package/skill-catalogue/locksmith/SKILL.md +42 -0
- package/skill-catalogue/messenger/SKILL.md +43 -0
- package/skill-catalogue/observer/SKILL.md +42 -0
- package/skill-catalogue/publisher/SKILL.md +64 -0
- package/skill-catalogue/starter/SKILL.md +48 -0
- package/skill-catalogue/strategist/SKILL.md +85 -0
- package/skill-catalogue/writer/SKILL.md +56 -0
- package/src/apply-plan-cli.ts +94 -0
- package/src/apply-plan.ts +172 -0
- package/src/check-cli.ts +7 -0
- package/src/cli.ts +51 -13
- package/src/contract.ts +81 -0
- package/src/core.ts +555 -38
- package/src/doctor-cli.ts +33 -0
- package/src/doctor.ts +145 -0
- package/src/host.ts +17 -1
- package/src/hosts.ts +79 -0
- package/src/index.ts +36 -0
- package/src/inventory-adoption.ts +85 -0
- package/src/manifest.ts +103 -0
- package/src/model-profile.ts +148 -0
- package/src/product-repository.ts +73 -0
- package/src/skills.ts +225 -0
- package/src/types.ts +74 -1
- /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
- /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { isDirectInvocation } from "./cli.js";
|
|
3
|
+
import { createNodeHost } from "./host.js";
|
|
4
|
+
import { renderDoctorReport, runDoctorChecks } from "./doctor.js";
|
|
5
|
+
|
|
6
|
+
export const DOCTOR_USAGE = `Usage: launcher-doctor
|
|
7
|
+
|
|
8
|
+
Read-only. Checks the prerequisites a client needs before the hub exists:
|
|
9
|
+
git, the GitHub command-line tool, whether you are signed in, Node.js, and
|
|
10
|
+
npm. Reports the first thing that is missing, in plain language, with the
|
|
11
|
+
next action to take -- never a dump of everything at once.
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 = ready, 2 = at least one prerequisite is missing.`;
|
|
14
|
+
|
|
15
|
+
export function main(argv: readonly string[]): number {
|
|
16
|
+
if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) {
|
|
17
|
+
console.log(DOCTOR_USAGE);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
if (argv.length !== 0) {
|
|
21
|
+
console.error("launcher-doctor: takes no arguments");
|
|
22
|
+
return 2;
|
|
23
|
+
}
|
|
24
|
+
const host = createNodeHost();
|
|
25
|
+
const report = runDoctorChecks(host);
|
|
26
|
+
console.log(renderDoctorReport(report));
|
|
27
|
+
return report.allSatisfied ? 0 : 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function run(): void {
|
|
31
|
+
process.exitCode = main(process.argv.slice(2));
|
|
32
|
+
}
|
|
33
|
+
if (isDirectInvocation(import.meta.url, process.argv[1])) run();
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// launcher doctor (#1220) -- read-only. Names each missing prerequisite in
|
|
2
|
+
// plain language, one at a time, in the order a non-technical client
|
|
3
|
+
// should fix them: git -> the GitHub command-line tool -> being signed in
|
|
4
|
+
// -> Node.js -> npm. A missing coding agent is advisory only -- Launcher
|
|
5
|
+
// cannot detect every host, and it is a one-time choice the client makes,
|
|
6
|
+
// not a step to fix in sequence.
|
|
7
|
+
|
|
8
|
+
import type { CommandResult } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export interface DoctorCheckHost {
|
|
11
|
+
run(command: string, args: readonly string[]): CommandResult;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DoctorStepId = "git" | "gh-cli" | "gh-auth" | "node" | "npm" | "coding-agent";
|
|
15
|
+
|
|
16
|
+
export interface DoctorStepResult {
|
|
17
|
+
readonly id: DoctorStepId;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
readonly satisfied: boolean;
|
|
20
|
+
/** Plain-language description of what is missing, present only when satisfied is false. */
|
|
21
|
+
readonly problem?: string;
|
|
22
|
+
/** The single next action, present only when satisfied is false. */
|
|
23
|
+
readonly nextAction?: string;
|
|
24
|
+
/** True for steps that inform but never block (e.g. coding-agent discovery). */
|
|
25
|
+
readonly advisory: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DoctorReport {
|
|
29
|
+
readonly schemaVersion: 1;
|
|
30
|
+
readonly steps: readonly DoctorStepResult[];
|
|
31
|
+
/** The first unsatisfied non-advisory step, in order -- what the client should fix right now. */
|
|
32
|
+
readonly nextToFix?: DoctorStepResult;
|
|
33
|
+
readonly allSatisfied: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ok(id: DoctorStepId, label: string, advisory = false): DoctorStepResult {
|
|
37
|
+
return { id, label, satisfied: true, advisory };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function missing(id: DoctorStepId, label: string, problem: string, nextAction: string, advisory = false): DoctorStepResult {
|
|
41
|
+
return { id, label, satisfied: false, problem, nextAction, advisory };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function commandSucceeds(host: DoctorCheckHost, command: string, args: readonly string[]): boolean {
|
|
45
|
+
try {
|
|
46
|
+
return host.run(command, args).status === 0;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Runs every doctor check in the fix-in-this-order sequence. Never mutates anything. */
|
|
53
|
+
export function runDoctorChecks(host: DoctorCheckHost): DoctorReport {
|
|
54
|
+
const steps: DoctorStepResult[] = [];
|
|
55
|
+
|
|
56
|
+
const hasGit = commandSucceeds(host, "git", ["--version"]);
|
|
57
|
+
steps.push(
|
|
58
|
+
hasGit
|
|
59
|
+
? ok("git", "Git is installed")
|
|
60
|
+
: missing(
|
|
61
|
+
"git",
|
|
62
|
+
"Git is installed",
|
|
63
|
+
"Git is not installed on this computer. Launcher and your coding agent both need it to save and share work.",
|
|
64
|
+
"Install Git from https://git-scm.com/downloads, then run this command again.",
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const hasGh = commandSucceeds(host, "gh", ["--version"]);
|
|
69
|
+
steps.push(
|
|
70
|
+
hasGh
|
|
71
|
+
? ok("gh-cli", "The GitHub command-line tool (gh) is installed")
|
|
72
|
+
: missing(
|
|
73
|
+
"gh-cli",
|
|
74
|
+
"The GitHub command-line tool (gh) is installed",
|
|
75
|
+
"The GitHub command-line tool is not installed. Launcher uses it to create and find your GitHub repositories.",
|
|
76
|
+
"Install it from https://cli.github.com, then run this command again.",
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const ghAuthed = hasGh && commandSucceeds(host, "gh", ["auth", "status"]);
|
|
81
|
+
steps.push(
|
|
82
|
+
!hasGh
|
|
83
|
+
? missing(
|
|
84
|
+
"gh-auth",
|
|
85
|
+
"You are signed in to GitHub",
|
|
86
|
+
"This cannot be checked yet because the GitHub command-line tool is not installed.",
|
|
87
|
+
"Install the GitHub command-line tool first (see above), then run this command again.",
|
|
88
|
+
)
|
|
89
|
+
: ghAuthed
|
|
90
|
+
? ok("gh-auth", "You are signed in to GitHub")
|
|
91
|
+
: missing(
|
|
92
|
+
"gh-auth",
|
|
93
|
+
"You are signed in to GitHub",
|
|
94
|
+
"You are not signed in to GitHub yet.",
|
|
95
|
+
"Run `gh auth login` and follow the prompts, then run this command again.",
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const hasNode = commandSucceeds(host, "node", ["--version"]);
|
|
100
|
+
steps.push(
|
|
101
|
+
hasNode
|
|
102
|
+
? ok("node", "Node.js is installed")
|
|
103
|
+
: missing(
|
|
104
|
+
"node",
|
|
105
|
+
"Node.js is installed",
|
|
106
|
+
"Node.js is not installed. It is what runs the team's tools on this computer.",
|
|
107
|
+
"Install the current LTS release from https://nodejs.org, then run this command again.",
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const hasNpm = commandSucceeds(host, "npm", ["--version"]);
|
|
112
|
+
steps.push(
|
|
113
|
+
hasNpm
|
|
114
|
+
? ok("npm", "npm is installed")
|
|
115
|
+
: missing(
|
|
116
|
+
"npm",
|
|
117
|
+
"npm is installed",
|
|
118
|
+
"npm is not installed. It comes with Node.js, so this is usually fixed by installing or reinstalling Node.js.",
|
|
119
|
+
"Install Node.js from https://nodejs.org (npm is included), then run this command again.",
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Advisory only: never blocks doctor's overall verdict. A missing coding
|
|
124
|
+
// agent is not a step to fix in a particular order -- it is a one-time
|
|
125
|
+
// choice the client makes, and Launcher cannot detect every host.
|
|
126
|
+
steps.push(ok("coding-agent", "A coding agent is available to open this hub", true));
|
|
127
|
+
|
|
128
|
+
const firstUnsatisfied = steps.find((step) => !step.satisfied && !step.advisory);
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
steps,
|
|
132
|
+
...(firstUnsatisfied === undefined ? {} : { nextToFix: firstUnsatisfied }),
|
|
133
|
+
allSatisfied: firstUnsatisfied === undefined,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Renders one step at a time, plain language, the way a non-technical client reads it. */
|
|
138
|
+
export function renderDoctorReport(report: DoctorReport): string {
|
|
139
|
+
if (report.allSatisfied) {
|
|
140
|
+
return "Everything doctor checks is ready. Open this folder in your coding agent and talk to @clossys-advisor to get started.";
|
|
141
|
+
}
|
|
142
|
+
const step = report.nextToFix;
|
|
143
|
+
if (step === undefined) return "Everything doctor checks is ready.";
|
|
144
|
+
return `${step.label}: not yet.\n${step.problem}\n\nNext: ${step.nextAction}`;
|
|
145
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, readSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
3
4
|
import type { CommandResult, WorkspaceHost } from "./types.js";
|
|
4
5
|
|
|
5
6
|
function run(command: string, args: readonly string[], options?: { cwd?: string }): CommandResult {
|
|
@@ -43,6 +44,13 @@ export function createNodeHost(cwd = process.cwd(), env: NodeJS.ProcessEnv = pro
|
|
|
43
44
|
now: () => new Date().toISOString(),
|
|
44
45
|
exists: (path) => existsSync(path),
|
|
45
46
|
isDirectory: (path) => existsSync(path) && statSync(path).isDirectory(),
|
|
47
|
+
isSymlink: (path) => {
|
|
48
|
+
try {
|
|
49
|
+
return lstatSync(path).isSymbolicLink();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
46
54
|
readText: (path) => {
|
|
47
55
|
try {
|
|
48
56
|
return readFileSync(path, "utf8");
|
|
@@ -56,6 +64,14 @@ export function createNodeHost(cwd = process.cwd(), env: NodeJS.ProcessEnv = pro
|
|
|
56
64
|
mkdirp: (path) => {
|
|
57
65
|
mkdirSync(path, { recursive: true });
|
|
58
66
|
},
|
|
67
|
+
symlink: (relativeTarget, linkPath) => {
|
|
68
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
69
|
+
if (existsSync(linkPath)) rmSync(linkPath, { recursive: true, force: true });
|
|
70
|
+
symlinkSync(relativeTarget, linkPath, "dir");
|
|
71
|
+
},
|
|
72
|
+
remove: (path) => {
|
|
73
|
+
rmSync(path, { recursive: true, force: true });
|
|
74
|
+
},
|
|
59
75
|
readDir: (path) => readdirSync(path),
|
|
60
76
|
run,
|
|
61
77
|
prompt,
|
package/src/hosts.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Records which coding-agent host(s) this run linked skill discovery for
|
|
2
|
+
// (#1180, Launcher side), so a consumer (Advisor's next-action phrasing)
|
|
3
|
+
// can name the client's actual tool instead of guessing.
|
|
4
|
+
//
|
|
5
|
+
// Codex discovery verified 2026-09-22 (developers.openai.com/codex/skills,
|
|
6
|
+
// developers.openai.com/codex/concepts/customization): Codex reads
|
|
7
|
+
// repository skills directly from `.agents/skills` -- the SAME directory
|
|
8
|
+
// skills.ts's AGENTS_SKILLS_REL already writes the real composed skill to.
|
|
9
|
+
// It needs no separate discovery symlink the way Claude Code (.claude/skills)
|
|
10
|
+
// and Cursor (.cursor/skills) do, so "codex" is detected by the presence of
|
|
11
|
+
// `.agents/skills` itself, not a host-specific prefix.
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { WorkspaceHost } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export type DiscoveredHost = "claude-code" | "cursor" | "codex";
|
|
17
|
+
|
|
18
|
+
export interface HostRecord {
|
|
19
|
+
readonly schemaVersion: 1;
|
|
20
|
+
/** Hosts whose discovery this directory currently carries. */
|
|
21
|
+
readonly linkedHosts: readonly DiscoveredHost[];
|
|
22
|
+
readonly recordedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const HOSTS_REL = join("clossys", ".state", "hosts.json");
|
|
26
|
+
|
|
27
|
+
const AGENTS_SKILLS_REL = join(".agents", "skills");
|
|
28
|
+
|
|
29
|
+
// Claude Code and Cursor need their own discovery symlink; Codex reads
|
|
30
|
+
// .agents/skills directly (see module header) and is detected separately.
|
|
31
|
+
const SYMLINK_DISCOVERY_PATHS: ReadonlyArray<{ host: DiscoveredHost; prefix: string }> = [
|
|
32
|
+
{ host: "claude-code", prefix: ".claude/skills" },
|
|
33
|
+
{ host: "cursor", prefix: ".cursor/skills" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Read-only: which hosts can actually discover skills in this directory right now. */
|
|
37
|
+
export function detectLinkedHosts(host: WorkspaceHost, directory: string): readonly DiscoveredHost[] {
|
|
38
|
+
const found: DiscoveredHost[] = [];
|
|
39
|
+
for (const { host: id, prefix } of SYMLINK_DISCOVERY_PATHS) {
|
|
40
|
+
if (host.isDirectory(join(directory, prefix)) || host.isSymlink(join(directory, prefix))) {
|
|
41
|
+
found.push(id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (host.isDirectory(join(directory, AGENTS_SKILLS_REL))) {
|
|
45
|
+
found.push("codex");
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function serializeHostRecord(record: HostRecord): string {
|
|
51
|
+
return `${JSON.stringify(record, null, 2)}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseHostRecord(raw: string | null): HostRecord | undefined {
|
|
55
|
+
if (raw === null) return undefined;
|
|
56
|
+
try {
|
|
57
|
+
const parsed: unknown = JSON.parse(raw);
|
|
58
|
+
if (
|
|
59
|
+
typeof parsed === "object" &&
|
|
60
|
+
parsed !== null &&
|
|
61
|
+
!Array.isArray(parsed) &&
|
|
62
|
+
(parsed as Record<string, unknown>).schemaVersion === 1 &&
|
|
63
|
+
Array.isArray((parsed as Record<string, unknown>).linkedHosts)
|
|
64
|
+
) {
|
|
65
|
+
const record = parsed as { linkedHosts: unknown[]; recordedAt: unknown };
|
|
66
|
+
const linkedHosts = record.linkedHosts.filter(
|
|
67
|
+
(item): item is DiscoveredHost => item === "claude-code" || item === "cursor" || item === "codex",
|
|
68
|
+
);
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
linkedHosts,
|
|
72
|
+
recordedAt: typeof record.recordedAt === "string" ? record.recordedAt : "",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
/* falls through */
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,29 +2,65 @@
|
|
|
2
2
|
export {
|
|
3
3
|
applyWorkspacePlan,
|
|
4
4
|
checkInventoryEntries,
|
|
5
|
+
cloneMissingInventoryRepositories,
|
|
5
6
|
formatHubHealth,
|
|
6
7
|
hasAdvisorPin,
|
|
7
8
|
inspectInventory,
|
|
8
9
|
isHubDocument,
|
|
10
|
+
launcherPackageRootFromModule,
|
|
9
11
|
observeWorkspace,
|
|
10
12
|
parseGitHubRemote,
|
|
11
13
|
planWorkspace,
|
|
14
|
+
readInventoryRepositories,
|
|
15
|
+
readLiveLauncherVersion,
|
|
12
16
|
reportHubHealth,
|
|
17
|
+
CLOSSYS_DIR_REL,
|
|
18
|
+
CLOSSYS_README_REL,
|
|
13
19
|
DEFAULT_REPOSITORY_NAME,
|
|
20
|
+
LEGACY_STATE_DIR_REL,
|
|
21
|
+
LEGACY_WORKSPACE_INVENTORY_REL,
|
|
22
|
+
LEGACY_WORKSPACE_MARKER_REL,
|
|
23
|
+
STATE_DIR_REL,
|
|
14
24
|
WORKSPACE_INVENTORY_REL,
|
|
15
25
|
WORKSPACE_MARKER_REL,
|
|
16
26
|
} from "./core.js";
|
|
27
|
+
export type { CloneMissingOutcome } from "./core.js";
|
|
28
|
+
export { runDoctorChecks, renderDoctorReport } from "./doctor.js";
|
|
29
|
+
export { applyEngagementBrief, isPlanApproved, validateAdvisorPlan, validateEngagementBrief } from "./apply-plan.js";
|
|
30
|
+
export type { AdvisorPlan, ApplyBriefResult, BlockerKind, EngagementBrief, EngagementBriefRole, PlanBlocker, PlanDecision, ValidationResult } from "./apply-plan.js";
|
|
31
|
+
export type { DoctorCheckHost, DoctorReport, DoctorStepId, DoctorStepResult } from "./doctor.js";
|
|
32
|
+
export { checkCloudSessionBootstrap } from "./product-repository.js";
|
|
33
|
+
export type { CloudBootstrapCheck, CloudBootstrapReport } from "./product-repository.js";
|
|
34
|
+
export { reportInventoryDrift } from "./inventory-adoption.js";
|
|
35
|
+
export type { ExternalInventoryDeclaration, InventoryDriftReport } from "./inventory-adoption.js";
|
|
36
|
+
export { detectLinkedHosts, parseHostRecord, serializeHostRecord, HOSTS_REL } from "./hosts.js";
|
|
37
|
+
export type { DiscoveredHost, HostRecord } from "./hosts.js";
|
|
38
|
+
export { parsePreferences, readHostModelProfile, resolveModelForTier } from "./model-profile.js";
|
|
17
39
|
export type {
|
|
40
|
+
BudgetPreference,
|
|
41
|
+
HostModelProfile,
|
|
42
|
+
HostTierMapping,
|
|
43
|
+
ModelResolution,
|
|
44
|
+
PreferencesDocument,
|
|
45
|
+
ReasoningTier,
|
|
46
|
+
SupportedHost,
|
|
47
|
+
} from "./model-profile.js";
|
|
48
|
+
export type {
|
|
49
|
+
ApplyWorkspaceOptions,
|
|
18
50
|
CommandResult,
|
|
19
51
|
CwdObservation,
|
|
20
52
|
DependencyBucket,
|
|
21
53
|
HubDocument,
|
|
22
54
|
HubHealthReport,
|
|
55
|
+
HubMigrationState,
|
|
23
56
|
InventoryObservation,
|
|
24
57
|
InventoryValidationEntry,
|
|
25
58
|
InventoryValidationReport,
|
|
26
59
|
PinFinding,
|
|
27
60
|
PinGrade,
|
|
61
|
+
SkillManifestDocument,
|
|
62
|
+
SkillManifestEntry,
|
|
63
|
+
SkillsManifestSummary,
|
|
28
64
|
WorkspaceApplyResult,
|
|
29
65
|
WorkspaceDecision,
|
|
30
66
|
WorkspaceHost,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Adopt an existing repository inventory instead of creating a second one
|
|
2
|
+
// (#1216). An account that already keeps a repository inventory in its own
|
|
3
|
+
// control plane becomes the source of truth when the hub marker declares
|
|
4
|
+
// it. Launcher writes only what that inventory lacks (a later apply step,
|
|
5
|
+
// not this module) and reports drift instead of silently merging.
|
|
6
|
+
|
|
7
|
+
import type { WorkspaceHost } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface ExternalInventoryDeclaration {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly shape: "foundry" | "custom";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InventoryDriftReport {
|
|
15
|
+
readonly status: "no-external-source" | "reconciled" | "indeterminate";
|
|
16
|
+
readonly externalOnly: readonly string[];
|
|
17
|
+
readonly launcherOnly: readonly string[];
|
|
18
|
+
readonly agreeing: readonly string[];
|
|
19
|
+
readonly note?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reads a foundry-shaped inventory document's repository ids. Malformed or missing is null, never []. */
|
|
27
|
+
function readForeignIds(host: WorkspaceHost, path: string): readonly string[] | null {
|
|
28
|
+
const raw = host.readText(path);
|
|
29
|
+
if (raw === null) return null;
|
|
30
|
+
let parsed: unknown;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.repositories)) return null;
|
|
37
|
+
const ids: string[] = [];
|
|
38
|
+
for (const entry of parsed.repositories) {
|
|
39
|
+
if (isRecord(entry) && typeof entry.id === "string" && entry.id.trim() !== "") ids.push(entry.id);
|
|
40
|
+
}
|
|
41
|
+
return ids;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compares the declared external inventory against the launcher-written
|
|
46
|
+
* inventory at `directory/launcherInventoryRelPath`. Read-only -- callers
|
|
47
|
+
* decide whether and how to write the reconciled set, as an explicit,
|
|
48
|
+
* approved apply step (same #1045 pattern as clone-on-approval).
|
|
49
|
+
*/
|
|
50
|
+
export function reportInventoryDrift(
|
|
51
|
+
host: WorkspaceHost,
|
|
52
|
+
directory: string,
|
|
53
|
+
declaration: ExternalInventoryDeclaration | undefined,
|
|
54
|
+
launcherInventoryRelPath: string,
|
|
55
|
+
): InventoryDriftReport {
|
|
56
|
+
if (declaration === undefined) {
|
|
57
|
+
return { status: "no-external-source", externalOnly: [], launcherOnly: [], agreeing: [] };
|
|
58
|
+
}
|
|
59
|
+
if (declaration.shape === "custom") {
|
|
60
|
+
return {
|
|
61
|
+
status: "indeterminate",
|
|
62
|
+
externalOnly: [],
|
|
63
|
+
launcherOnly: [],
|
|
64
|
+
agreeing: [],
|
|
65
|
+
note: `externalInventory at ${declaration.path} declares shape "custom"; launcher has no mapping for a non-foundry inventory shape yet and will not guess one. Reconcile by hand or file the mapping gap.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const externalIds = readForeignIds(host, declaration.path);
|
|
69
|
+
if (externalIds === null) {
|
|
70
|
+
return {
|
|
71
|
+
status: "indeterminate",
|
|
72
|
+
externalOnly: [],
|
|
73
|
+
launcherOnly: [],
|
|
74
|
+
agreeing: [],
|
|
75
|
+
note: `externalInventory at ${declaration.path} could not be read as a populated schemaVersion:1 inventory document.`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const launcherIds = readForeignIds(host, `${directory}/${launcherInventoryRelPath}`) ?? [];
|
|
79
|
+
const externalSet = new Set(externalIds);
|
|
80
|
+
const launcherSet = new Set(launcherIds);
|
|
81
|
+
const externalOnly = externalIds.filter((id) => !launcherSet.has(id));
|
|
82
|
+
const launcherOnly = launcherIds.filter((id) => !externalSet.has(id));
|
|
83
|
+
const agreeing = externalIds.filter((id) => launcherSet.has(id));
|
|
84
|
+
return { status: "reconciled", externalOnly, launcherOnly, agreeing };
|
|
85
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { SkillManifestDocument, SkillManifestEntry, SkillsManifestSummary, WorkspaceHost } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** sha256 hex digest of a skill's final composed content (post contract injection). */
|
|
10
|
+
export function sha256Hex(content: string): string {
|
|
11
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Tiny semver-ish compare, duplicated from core.ts to keep this module dependency-free within the package. */
|
|
15
|
+
function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
|
|
16
|
+
const parse = (version: string): readonly number[] | null => {
|
|
17
|
+
const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
|
|
18
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
19
|
+
};
|
|
20
|
+
const left = parse(a);
|
|
21
|
+
const right = parse(b);
|
|
22
|
+
if (!left || !right) return null;
|
|
23
|
+
for (let index = 0; index < 3; index += 1) {
|
|
24
|
+
const l = left[index] ?? 0;
|
|
25
|
+
const r = right[index] ?? 0;
|
|
26
|
+
if (l < r) return -1;
|
|
27
|
+
if (l > r) return 1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parses `clossys/.state/skills.json`. An unreadable or malformed document is treated as absent. */
|
|
33
|
+
export function parseSkillManifest(raw: string | null): SkillManifestDocument | undefined {
|
|
34
|
+
if (raw === null) return undefined;
|
|
35
|
+
let parsed: unknown;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== 1 || !Array.isArray(parsed.skills)) return undefined;
|
|
42
|
+
const skills: SkillManifestEntry[] = [];
|
|
43
|
+
for (const entry of parsed.skills) {
|
|
44
|
+
if (!isRecord(entry)) continue;
|
|
45
|
+
const { name, source, sha256 } = entry;
|
|
46
|
+
if (typeof name !== "string" || name.trim() === "") continue;
|
|
47
|
+
if (source !== "installed" && source !== "catalogue") continue;
|
|
48
|
+
if (typeof sha256 !== "string" || sha256.trim() === "") continue;
|
|
49
|
+
skills.push({
|
|
50
|
+
name,
|
|
51
|
+
source,
|
|
52
|
+
sha256,
|
|
53
|
+
...(typeof entry.version === "string" && entry.version.trim() !== "" ? { version: entry.version } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 1,
|
|
58
|
+
generatedAt: typeof parsed.generatedAt === "string" ? parsed.generatedAt : "",
|
|
59
|
+
skills,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function serializeSkillManifest(document: SkillManifestDocument): string {
|
|
64
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Freshness summary for the health report: "N skills out of date, M
|
|
69
|
+
* retired". `stale` counts catalogue-sourced entries whose recorded version
|
|
70
|
+
* is older than `liveLauncherVersion` (a client running an old launcher
|
|
71
|
+
* carries a catalogue only as fresh as that release, #1033) — unknown or
|
|
72
|
+
* unparseable versions are never counted stale. `retiredThisRun` is supplied
|
|
73
|
+
* by the composition step that just ran; a read-only inspection (no
|
|
74
|
+
* composition) reports 0 retired, which is correct — retirement is a
|
|
75
|
+
* this-run event, not a property of the manifest at rest.
|
|
76
|
+
*/
|
|
77
|
+
export function summarizeSkillsManifest(
|
|
78
|
+
manifest: SkillManifestDocument | undefined,
|
|
79
|
+
liveLauncherVersion: string | undefined,
|
|
80
|
+
retiredThisRun: readonly string[],
|
|
81
|
+
): SkillsManifestSummary {
|
|
82
|
+
if (manifest === undefined) {
|
|
83
|
+
return { status: "missing", total: 0, stale: 0, retired: retiredThisRun.length };
|
|
84
|
+
}
|
|
85
|
+
const stale = manifest.skills.filter((entry) => {
|
|
86
|
+
if (entry.source !== "catalogue" || liveLauncherVersion === undefined || entry.version === undefined) return false;
|
|
87
|
+
return compareVersions(entry.version, liveLauncherVersion) === -1;
|
|
88
|
+
}).length;
|
|
89
|
+
return { status: "present", total: manifest.skills.length, stale, retired: retiredThisRun.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reads `installed` version from `<composeTargetDirectory>/node_modules/@clossys/<pkg>/package.json`. */
|
|
93
|
+
export function readInstalledVersion(host: WorkspaceHost, composeTargetDirectory: string, packageDir: string): string | undefined {
|
|
94
|
+
const raw = host.readText(join(composeTargetDirectory, "node_modules", "@clossys", packageDir, "package.json"));
|
|
95
|
+
if (raw === null) return undefined;
|
|
96
|
+
try {
|
|
97
|
+
const parsed: unknown = JSON.parse(raw);
|
|
98
|
+
if (isRecord(parsed) && typeof parsed.version === "string" && parsed.version.trim() !== "") return parsed.version;
|
|
99
|
+
} catch {
|
|
100
|
+
/* unreadable manifest; no version */
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|