@alessandroraffa/tangyr 0.11.0 → 0.12.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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/index.js +676 -69
  3. package/package.json +9 -3
package/README.md CHANGED
@@ -4,6 +4,10 @@ CLI for the Tangyr discipline — install and manage operating kits for AI codin
4
4
 
5
5
  An operating kit is a self-contained package of agents, skills, commands, rules, and templates that implements the Tangyr discipline for a specific AI coding context. The CLI places kit artifacts into the environments consumed by supported tools.
6
6
 
7
+ ## Requirements
8
+
9
+ Node.js `>=22`. Running `tangyr` on an older Node.js version prints a clear error to stderr and exits without attempting to run any command.
10
+
7
11
  ## Install / run
8
12
 
9
13
  ```sh
@@ -49,6 +53,16 @@ tangyr <command>
49
53
  | `tangyr config` | Display or edit Tangyr configuration |
50
54
  | `tangyr validate` | Validate the kit against kitFormat 1 conformity rules |
51
55
 
56
+ ### Authentication
57
+
58
+ | Command | Description |
59
+ | -------------------- | --------------------------------------------------------------- |
60
+ | `tangyr auth login` | Store a client ID/secret credential pair, validating by default |
61
+ | `tangyr auth status` | Show current credential validation state |
62
+ | `tangyr auth logout` | Remove the stored credential |
63
+
64
+ Resolved environment-first (`TANGYR_ACCESS_CLIENT_ID`/`TANGYR_ACCESS_CLIENT_SECRET`), then from the stored file, then interactively. A full quickstart (configuring a remote kit origin and installing against it) lands once remote kit resolution ships.
65
+
52
66
  ## Global flags
53
67
 
54
68
  | Flag | Description |
package/dist/index.js CHANGED
@@ -14,7 +14,11 @@ var __require = /* @__PURE__ */ ((x2) => typeof require !== "undefined" ? requir
14
14
  throw Error('Dynamic require of "' + x2 + '" is not supported');
15
15
  });
16
16
  var __commonJS = (cb, mod) => function __require2() {
17
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
+ try {
18
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
19
+ } catch (e) {
20
+ throw mod = 0, e;
21
+ }
18
22
  };
19
23
  var __copyProps = (to, from, except, desc) => {
20
24
  if (from && typeof from === "object" || typeof from === "function") {
@@ -14761,6 +14765,60 @@ var dist_default6 = createPrompt((config, done) => {
14761
14765
  ];
14762
14766
  });
14763
14767
 
14768
+ // node_modules/@inquirer/password/dist/index.js
14769
+ var passwordTheme = {
14770
+ style: {
14771
+ maskedText: "[input is masked]"
14772
+ }
14773
+ };
14774
+ var dist_default7 = createPrompt((config, done) => {
14775
+ const { validate = () => true } = config;
14776
+ const theme = makeTheme(passwordTheme, config.theme);
14777
+ const [status, setStatus] = useState("idle");
14778
+ const [errorMsg, setError] = useState();
14779
+ const [value, setValue] = useState("");
14780
+ const prefix = usePrefix({ status, theme });
14781
+ useKeypress(async (key, rl) => {
14782
+ if (status !== "idle") {
14783
+ return;
14784
+ }
14785
+ if (isEnterKey(key)) {
14786
+ const answer = value;
14787
+ setStatus("loading");
14788
+ const isValid = await validate(answer);
14789
+ if (isValid === true) {
14790
+ setValue(answer);
14791
+ setStatus("done");
14792
+ done(answer);
14793
+ } else {
14794
+ rl.write(value);
14795
+ setError(isValid || "You must provide a valid value");
14796
+ setStatus("idle");
14797
+ }
14798
+ } else {
14799
+ setValue(rl.line);
14800
+ setError(void 0);
14801
+ }
14802
+ });
14803
+ const message = theme.style.message(config.message, status);
14804
+ let formattedValue = "";
14805
+ let helpTip;
14806
+ if (config.mask) {
14807
+ const maskChar = typeof config.mask === "string" ? config.mask : "*";
14808
+ formattedValue = maskChar.repeat(value.length);
14809
+ } else if (status !== "done") {
14810
+ helpTip = `${theme.style.help(theme.style.maskedText)}${cursorHide}`;
14811
+ }
14812
+ if (status === "done") {
14813
+ formattedValue = theme.style.answer(formattedValue);
14814
+ }
14815
+ let error = "";
14816
+ if (errorMsg) {
14817
+ error = theme.style.error(errorMsg);
14818
+ }
14819
+ return [[prefix, message, config.mask ? formattedValue : helpTip].join(" "), error];
14820
+ });
14821
+
14764
14822
  // node_modules/@inquirer/select/dist/index.js
14765
14823
  import { styleText as styleText4 } from "util";
14766
14824
  var selectTheme = {
@@ -14806,7 +14864,7 @@ function normalizeChoices2(choices) {
14806
14864
  return normalizedChoice;
14807
14865
  });
14808
14866
  }
14809
- var dist_default7 = createPrompt((config, done) => {
14867
+ var dist_default8 = createPrompt((config, done) => {
14810
14868
  const { loop = true, pageSize = 7 } = config;
14811
14869
  const theme = makeTheme(selectTheme, config.theme);
14812
14870
  const { keybindings } = theme;
@@ -15093,9 +15151,12 @@ function getPackageRoot(importMetaUrl) {
15093
15151
  current = parent;
15094
15152
  }
15095
15153
  }
15154
+ function computeBytesHash(content) {
15155
+ return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
15156
+ }
15096
15157
  function computeSourceHash(filePath) {
15097
15158
  const content = fs2.readFileSync(filePath);
15098
- return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`;
15159
+ return computeBytesHash(content);
15099
15160
  }
15100
15161
  function formatProvenanceMarker(format, source, hash, target) {
15101
15162
  if (format === "markdown") {
@@ -15404,6 +15465,11 @@ var EXIT_ERROR_SEVERITY_LOSS = 3;
15404
15465
  var EXIT_TARGET_NOT_DETECTED = 4;
15405
15466
  var EXIT_CONFIG_MISSING = 10;
15406
15467
  var EXIT_USER_INTERRUPT = 130;
15468
+ var EXIT_AUTH_REQUIRED = 5;
15469
+ var EXIT_REMOTE_UNAVAILABLE = 6;
15470
+ var EXIT_INTEGRITY_FAILURE = 7;
15471
+ var EXIT_OFFLINE_CACHE_MISS = 8;
15472
+ var EXIT_INCOMPATIBLE_VERSION = 9;
15407
15473
  var ConflictError = class extends Error {
15408
15474
  constructor(message) {
15409
15475
  super(message);
@@ -15416,6 +15482,44 @@ var TargetNotDetectedError = class extends Error {
15416
15482
  this.name = "TargetNotDetectedError";
15417
15483
  }
15418
15484
  };
15485
+ var AuthenticationError = class extends Error {
15486
+ constructor(message) {
15487
+ super(message);
15488
+ this.name = "AuthenticationError";
15489
+ }
15490
+ };
15491
+ var RemoteUnavailableError = class extends Error {
15492
+ constructor(message) {
15493
+ super(message);
15494
+ this.name = "RemoteUnavailableError";
15495
+ }
15496
+ };
15497
+ var IntegrityError = class extends Error {
15498
+ constructor(message) {
15499
+ super(message);
15500
+ this.name = "IntegrityError";
15501
+ }
15502
+ };
15503
+ var OfflineCacheMissError = class extends Error {
15504
+ constructor(message) {
15505
+ super(message);
15506
+ this.name = "OfflineCacheMissError";
15507
+ }
15508
+ };
15509
+ var IncompatibleVersionError = class extends Error {
15510
+ constructor(message) {
15511
+ super(message);
15512
+ this.name = "IncompatibleVersionError";
15513
+ }
15514
+ };
15515
+ var CredentialsLegacyError = class extends Error {
15516
+ constructor() {
15517
+ super(
15518
+ "Legacy single-key credential file detected. The credential-store layout changed to store a client ID/secret pair (the credential-pair format). Run 'tangyr auth login' to re-authenticate and store the new format."
15519
+ );
15520
+ this.name = "CredentialsLegacyError";
15521
+ }
15522
+ };
15419
15523
  function handleFatalError(err, logger) {
15420
15524
  const error = err instanceof Error ? err : new Error(String(err));
15421
15525
  logger.error(error.message);
@@ -15428,6 +15532,24 @@ function handleFatalError(err, logger) {
15428
15532
  if (error instanceof TargetNotDetectedError) {
15429
15533
  process.exit(EXIT_TARGET_NOT_DETECTED);
15430
15534
  }
15535
+ if (error instanceof AuthenticationError) {
15536
+ process.exit(EXIT_AUTH_REQUIRED);
15537
+ }
15538
+ if (error instanceof CredentialsLegacyError) {
15539
+ process.exit(EXIT_AUTH_REQUIRED);
15540
+ }
15541
+ if (error instanceof RemoteUnavailableError) {
15542
+ process.exit(EXIT_REMOTE_UNAVAILABLE);
15543
+ }
15544
+ if (error instanceof IntegrityError) {
15545
+ process.exit(EXIT_INTEGRITY_FAILURE);
15546
+ }
15547
+ if (error instanceof OfflineCacheMissError) {
15548
+ process.exit(EXIT_OFFLINE_CACHE_MISS);
15549
+ }
15550
+ if (error instanceof IncompatibleVersionError) {
15551
+ process.exit(EXIT_INCOMPATIBLE_VERSION);
15552
+ }
15431
15553
  process.exit(EXIT_FAILURE);
15432
15554
  }
15433
15555
  function exitOnErrorSeverityLoss(report, logger, opts = {}) {
@@ -15444,6 +15566,28 @@ function exitOnErrorSeverityLoss(report, logger, opts = {}) {
15444
15566
  }
15445
15567
  }
15446
15568
 
15569
+ // src/core/runtime-guard.ts
15570
+ var MINIMUM_SUPPORTED_NODE_MAJOR = 22;
15571
+ function checkNodeRuntime(nodeVersion) {
15572
+ const match = /^v?(\d+)\./.exec(nodeVersion);
15573
+ const major = match ? Number.parseInt(match[1], 10) : Number.NaN;
15574
+ if (Number.isNaN(major) || major < MINIMUM_SUPPORTED_NODE_MAJOR) {
15575
+ return {
15576
+ supported: false,
15577
+ message: `tangyr requires Node.js >=${MINIMUM_SUPPORTED_NODE_MAJOR}. Detected ${nodeVersion}. Install Node.js ${MINIMUM_SUPPORTED_NODE_MAJOR} or later (e.g. via nvm: "nvm install ${MINIMUM_SUPPORTED_NODE_MAJOR}") and retry.`
15578
+ };
15579
+ }
15580
+ return { supported: true };
15581
+ }
15582
+ function assertSupportedNodeRuntime(nodeVersion = process.version) {
15583
+ const result = checkNodeRuntime(nodeVersion);
15584
+ if (!result.supported) {
15585
+ process.stderr.write(`${result.message}
15586
+ `);
15587
+ process.exit(EXIT_FAILURE);
15588
+ }
15589
+ }
15590
+
15447
15591
  // src/commands/assess.ts
15448
15592
  import fs11 from "fs";
15449
15593
  import path12 from "path";
@@ -16926,63 +17070,364 @@ function resolveKitPath(config, configDir) {
16926
17070
  import fs12 from "fs";
16927
17071
  import os3 from "os";
16928
17072
  import path13 from "path";
17073
+
17074
+ // src/core/transport.ts
17075
+ var KIT_CONTRACT_VERSION = "1";
17076
+ var CONTRACT_VERSION_HEADER = "X-Tangyr-Contract-Version";
17077
+ var ACCESS_CLIENT_ID_HEADER = "CF-Access-Client-Id";
17078
+ var ACCESS_CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
17079
+ var REDACTED = "[redacted]";
17080
+ function assertHttps(originUrl) {
17081
+ const parsed = new URL(originUrl);
17082
+ if (parsed.protocol !== "https:") {
17083
+ throw new IntegrityError(
17084
+ `KitTransport rejected a non-HTTPS origin "${originUrl}" \u2014 only https:// origins are permitted (REQ-INT-006).`
17085
+ );
17086
+ }
17087
+ }
17088
+ async function performRequest(originUrl, requestPath, credentials, logger) {
17089
+ assertHttps(originUrl);
17090
+ const headers = {
17091
+ [ACCESS_CLIENT_ID_HEADER]: credentials.clientId,
17092
+ [ACCESS_CLIENT_SECRET_HEADER]: credentials.clientSecret,
17093
+ [CONTRACT_VERSION_HEADER]: KIT_CONTRACT_VERSION
17094
+ };
17095
+ logger?.verbose(
17096
+ `KitTransport GET ${originUrl}${requestPath} \u2014 headers: ${ACCESS_CLIENT_ID_HEADER}=${REDACTED}, ${ACCESS_CLIENT_SECRET_HEADER}=${REDACTED}, ${CONTRACT_VERSION_HEADER}=${KIT_CONTRACT_VERSION}`
17097
+ );
17098
+ const response = await fetch(`${originUrl}${requestPath}`, {
17099
+ method: "GET",
17100
+ redirect: "manual",
17101
+ headers
17102
+ });
17103
+ if (response.status >= 300 && response.status < 400) {
17104
+ throw new IntegrityError(
17105
+ `KitTransport rejected a redirect response (HTTP ${response.status}) from ${originUrl}${requestPath} \u2014 redirects are never followed; every redirect is rejected fail-closed (REQ-INT-006, resolved reject-all-redirects policy).`
17106
+ );
17107
+ }
17108
+ if (response.status === 426) {
17109
+ throw new IncompatibleVersionError(
17110
+ `The origin rejected this CLI's contract version (HTTP 426) at ${originUrl}${requestPath}. Upgrade tangyr to the latest release and retry.`
17111
+ );
17112
+ }
17113
+ return response;
17114
+ }
17115
+ function createFetchKitTransport(options = {}) {
17116
+ return {
17117
+ async probe(originUrl, credentials) {
17118
+ const response = await performRequest(
17119
+ originUrl,
17120
+ "/v1/probe",
17121
+ credentials,
17122
+ options.logger
17123
+ );
17124
+ const contentType = response.headers.get("content-type") ?? "";
17125
+ const metadata = contentType.includes("application/json") ? await response.json() : {};
17126
+ return { status: response.status, metadata };
17127
+ },
17128
+ async fetchObject(originUrl, objectPath, credentials) {
17129
+ const response = await performRequest(
17130
+ originUrl,
17131
+ objectPath,
17132
+ credentials,
17133
+ options.logger
17134
+ );
17135
+ const body = Buffer.from(await response.arrayBuffer());
17136
+ return { status: response.status, body };
17137
+ }
17138
+ };
17139
+ }
17140
+
17141
+ // src/commands/auth.ts
16929
17142
  var CREDENTIALS_DIR = path13.join(os3.homedir(), ".tangyr");
16930
17143
  var CREDENTIALS_PATH = path13.join(CREDENTIALS_DIR, "credentials.json");
16931
- async function runAuthLoginCommand(options, logger) {
16932
- let key = options.key;
16933
- if (!key) {
16934
- key = await dist_default6({ message: "Enter your Tangyr license key:" });
17144
+ function isCurrentCredentialRecord(value) {
17145
+ if (typeof value !== "object" || value === null) {
17146
+ return false;
16935
17147
  }
16936
- if (!key || key.trim().length === 0) {
16937
- logger.error("No license key provided.");
16938
- process.exit(1);
17148
+ const record = value;
17149
+ return record.schema === 2 && typeof record.clientId === "string" && record.clientId.length > 0 && typeof record.clientSecret === "string" && record.clientSecret.length > 0 && (record.validation === "validated" || record.validation === "unvalidated") && typeof record.storedAt === "string";
17150
+ }
17151
+ function readStoredCredentials() {
17152
+ if (!fs12.existsSync(CREDENTIALS_PATH)) {
17153
+ return null;
16939
17154
  }
16940
- fs12.mkdirSync(CREDENTIALS_DIR, { recursive: true });
16941
- const credentials = {
16942
- key: key.trim(),
16943
- storedAt: (/* @__PURE__ */ new Date()).toISOString()
16944
- };
16945
- fs12.writeFileSync(
16946
- CREDENTIALS_PATH,
16947
- `${JSON.stringify(credentials, null, 2)}
16948
- `,
16949
- { mode: 384 }
16950
- );
17155
+ const raw = fs12.readFileSync(CREDENTIALS_PATH, "utf8");
17156
+ const parsed = JSON.parse(raw);
17157
+ if (!isCurrentCredentialRecord(parsed)) {
17158
+ throw new CredentialsLegacyError();
17159
+ }
17160
+ return parsed;
17161
+ }
17162
+ function ensureCredentialsDir(logger) {
16951
17163
  try {
16952
- fs12.chmodSync(CREDENTIALS_PATH, 384);
17164
+ fs12.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 448 });
17165
+ } catch (err) {
17166
+ if (err.code !== "EEXIST") {
17167
+ throw err;
17168
+ }
17169
+ }
17170
+ if (fs12.lstatSync(CREDENTIALS_DIR).isSymbolicLink()) {
17171
+ throw new Error(
17172
+ `Refusing to write credentials: "${CREDENTIALS_DIR}" already exists as a symbolic link, not a directory. Remove it and run 'tangyr auth login' again.`
17173
+ );
17174
+ }
17175
+ try {
17176
+ fs12.chmodSync(CREDENTIALS_DIR, 448);
16953
17177
  } catch {
17178
+ logger.warn(
17179
+ "Could not set owner-only (0700) permissions on the credentials directory on this platform; at-rest protection is reduced (REQ-AUT-007)."
17180
+ );
16954
17181
  }
16955
- const masked = `${key.trim().slice(0, 4)}...${key.trim().slice(-4)}`;
16956
- logger.info(`License key stored (${masked}).`);
16957
- logger.info(
16958
- "Remote validation is not yet implemented \u2014 key stored locally only."
17182
+ }
17183
+ function writeStoredCredentials(record, logger) {
17184
+ ensureCredentialsDir(logger);
17185
+ const content = `${JSON.stringify(record, null, 2)}
17186
+ `;
17187
+ const tmpPath = `${CREDENTIALS_PATH}.tmp`;
17188
+ try {
17189
+ fs12.writeFileSync(tmpPath, content, { mode: 384, flag: "wx" });
17190
+ } catch (err) {
17191
+ if (err.code === "EEXIST") {
17192
+ fs12.unlinkSync(tmpPath);
17193
+ fs12.writeFileSync(tmpPath, content, { mode: 384, flag: "wx" });
17194
+ } else {
17195
+ throw err;
17196
+ }
17197
+ }
17198
+ try {
17199
+ fs12.renameSync(tmpPath, CREDENTIALS_PATH);
17200
+ } catch (renameErr) {
17201
+ try {
17202
+ try {
17203
+ const destStat = fs12.lstatSync(CREDENTIALS_PATH);
17204
+ if (destStat.isSymbolicLink()) {
17205
+ fs12.unlinkSync(CREDENTIALS_PATH);
17206
+ }
17207
+ } catch {
17208
+ }
17209
+ fs12.copyFileSync(tmpPath, CREDENTIALS_PATH);
17210
+ try {
17211
+ fs12.chmodSync(CREDENTIALS_PATH, 384);
17212
+ } catch {
17213
+ logger.warn(
17214
+ "Could not set owner-only (0600) permissions on the credentials file on this platform; at-rest protection is reduced (REQ-AUT-007)."
17215
+ );
17216
+ }
17217
+ try {
17218
+ fs12.unlinkSync(tmpPath);
17219
+ } catch {
17220
+ }
17221
+ } catch (copyErr) {
17222
+ try {
17223
+ fs12.unlinkSync(tmpPath);
17224
+ } catch {
17225
+ }
17226
+ throw new Error(
17227
+ `writeStoredCredentials: failed to write ${CREDENTIALS_PATH} \u2014 ${String(renameErr)} / fallback copy failed \u2014 ${String(copyErr)}`
17228
+ );
17229
+ }
17230
+ }
17231
+ }
17232
+ var ADVANCE_EXPIRY_WARNING_DAYS = 30;
17233
+ var DEFINITIVE_DENIAL_STATUSES = /* @__PURE__ */ new Set([401, 403]);
17234
+ async function resolveCredentialForLogin(options, logger) {
17235
+ const envClientId = process.env.TANGYR_ACCESS_CLIENT_ID?.trim();
17236
+ const envClientSecret = process.env.TANGYR_ACCESS_CLIENT_SECRET?.trim();
17237
+ if (envClientId && envClientSecret) {
17238
+ logger.verbose(
17239
+ "Resolved credential from TANGYR_ACCESS_CLIENT_ID / TANGYR_ACCESS_CLIENT_SECRET."
17240
+ );
17241
+ return {
17242
+ pair: { clientId: envClientId, clientSecret: envClientSecret },
17243
+ source: "env"
17244
+ };
17245
+ }
17246
+ if (envClientId || envClientSecret) {
17247
+ throw new AuthenticationError(
17248
+ "Both TANGYR_ACCESS_CLIENT_ID and TANGYR_ACCESS_CLIENT_SECRET must be set together. Set both environment variables, or unset both and run 'tangyr auth login' interactively."
17249
+ );
17250
+ }
17251
+ const stored = readStoredCredentials();
17252
+ if (stored) {
17253
+ logger.verbose("Resolved credential from the stored credential file.");
17254
+ return {
17255
+ pair: { clientId: stored.clientId, clientSecret: stored.clientSecret },
17256
+ source: "stored"
17257
+ };
17258
+ }
17259
+ if (options.nonInteractive) {
17260
+ throw new AuthenticationError(
17261
+ "No credential available under --non-interactive. Set TANGYR_ACCESS_CLIENT_ID and TANGYR_ACCESS_CLIENT_SECRET, or drop --non-interactive and run 'tangyr auth login' again from an interactive terminal."
17262
+ );
17263
+ }
17264
+ const clientId = await dist_default6({
17265
+ message: "Enter your Tangyr access client ID:"
17266
+ });
17267
+ const clientSecret = await dist_default7({
17268
+ message: "Enter your Tangyr access client secret:"
17269
+ });
17270
+ if (clientId.trim().length === 0 || clientSecret.trim().length === 0) {
17271
+ throw new AuthenticationError("No credential provided.");
17272
+ }
17273
+ return {
17274
+ pair: { clientId: clientId.trim(), clientSecret: clientSecret.trim() },
17275
+ source: "interactive"
17276
+ };
17277
+ }
17278
+ function resolveOriginUrl(configPath) {
17279
+ let config;
17280
+ try {
17281
+ ({ config } = resolveConfig(configPath));
17282
+ } catch (err) {
17283
+ if (err instanceof ConfigNotFoundError) {
17284
+ return void 0;
17285
+ }
17286
+ throw err;
17287
+ }
17288
+ const source = config.source;
17289
+ return typeof source.url === "string" && source.url.length > 0 ? source.url : void 0;
17290
+ }
17291
+ async function validateAgainstOrigin(originUrl, pair, transport, logger) {
17292
+ if (!originUrl) {
17293
+ logger.verbose(
17294
+ "No remote origin configured (source.url) \u2014 treating as unreachable for validation purposes."
17295
+ );
17296
+ return { outcome: "unreachable" };
17297
+ }
17298
+ try {
17299
+ const result = await transport.probe(originUrl, pair);
17300
+ if (DEFINITIVE_DENIAL_STATUSES.has(result.status)) {
17301
+ return { outcome: "denied" };
17302
+ }
17303
+ if (result.status >= 200 && result.status < 300) {
17304
+ return { outcome: "success", metadata: result.metadata };
17305
+ }
17306
+ return { outcome: "unreachable" };
17307
+ } catch (err) {
17308
+ if (err instanceof IntegrityError || err instanceof IncompatibleVersionError) {
17309
+ throw err;
17310
+ }
17311
+ logger.verbose(
17312
+ `Origin probe failed: ${err instanceof Error ? err.message : String(err)}`
17313
+ );
17314
+ return { outcome: "unreachable" };
17315
+ }
17316
+ }
17317
+ async function runAuthLoginCommand(options, logger, transport = createFetchKitTransport({ logger })) {
17318
+ const resolved = await resolveCredentialForLogin(options, logger);
17319
+ if (options.skipValidation) {
17320
+ writeStoredCredentials(
17321
+ {
17322
+ schema: 2,
17323
+ clientId: resolved.pair.clientId,
17324
+ clientSecret: resolved.pair.clientSecret,
17325
+ validation: "unvalidated",
17326
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
17327
+ },
17328
+ logger
17329
+ );
17330
+ logger.info(
17331
+ "Credential stored (validation skipped via --skip-validation)."
17332
+ );
17333
+ logger.warn(
17334
+ "Validation state: stored, unvalidated. It will be revalidated on the next command that reaches the configured origin."
17335
+ );
17336
+ return;
17337
+ }
17338
+ const originUrl = resolveOriginUrl(options.config);
17339
+ const outcome = await validateAgainstOrigin(
17340
+ originUrl,
17341
+ resolved.pair,
17342
+ transport,
17343
+ logger
17344
+ );
17345
+ if (outcome.outcome === "denied") {
17346
+ throw new AuthenticationError(
17347
+ "The configured origin denied this credential. The credential was not stored. Obtain a valid client ID/secret pair and run 'tangyr auth login' again."
17348
+ );
17349
+ }
17350
+ writeStoredCredentials(
17351
+ {
17352
+ schema: 2,
17353
+ clientId: resolved.pair.clientId,
17354
+ clientSecret: resolved.pair.clientSecret,
17355
+ validation: outcome.outcome === "success" ? "validated" : "unvalidated",
17356
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
17357
+ },
17358
+ logger
16959
17359
  );
17360
+ if (outcome.outcome === "success") {
17361
+ logger.info("Credential validated and stored (stored, validated).");
17362
+ } else {
17363
+ logger.warn(
17364
+ "Could not reach the configured origin to validate this credential. Credential stored as stored, unvalidated \u2014 it will be revalidated on the next command that reaches the origin."
17365
+ );
17366
+ }
16960
17367
  }
16961
- function runAuthStatusCommand(_options, logger) {
16962
- if (!fs12.existsSync(CREDENTIALS_PATH)) {
17368
+ async function runAuthStatusCommand(options, logger, transport = createFetchKitTransport({ logger })) {
17369
+ const stored = readStoredCredentials();
17370
+ if (!stored) {
16963
17371
  logger.info(
16964
- "Not authenticated. Run 'tangyr auth login' to configure a license key."
17372
+ "Not authenticated. Run 'tangyr auth login' to configure a credential."
16965
17373
  );
16966
17374
  return;
16967
17375
  }
16968
- try {
16969
- const raw = fs12.readFileSync(CREDENTIALS_PATH, "utf8");
16970
- const credentials = JSON.parse(raw);
16971
- const masked = `${credentials.key.slice(0, 4)}...${credentials.key.slice(-4)}`;
16972
- logger.info(`Authenticated: ${masked}`);
16973
- logger.info(`Stored at: ${credentials.storedAt}`);
16974
- logger.info("Remote validation: not yet implemented.");
16975
- } catch {
16976
- logger.error("Failed to read credentials file. It may be corrupted.");
17376
+ const originUrl = resolveOriginUrl(options.config);
17377
+ const outcome = await validateAgainstOrigin(
17378
+ originUrl,
17379
+ { clientId: stored.clientId, clientSecret: stored.clientSecret },
17380
+ transport,
17381
+ logger
17382
+ );
17383
+ if (outcome.outcome === "denied") {
17384
+ throw new AuthenticationError(
17385
+ "The configured origin denied the stored credential \u2014 it has likely been revoked. Run 'tangyr auth login' with a new credential."
17386
+ );
17387
+ }
17388
+ if (outcome.outcome === "unreachable") {
17389
+ logger.info(`Credential status: stored, ${stored.validation}.`);
17390
+ logger.info(
17391
+ "The configured origin was not reached \u2014 label and expiry are not reported."
17392
+ );
17393
+ return;
17394
+ }
17395
+ if (stored.validation === "unvalidated") {
17396
+ writeStoredCredentials(
17397
+ {
17398
+ ...stored,
17399
+ validation: "validated",
17400
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
17401
+ },
17402
+ logger
17403
+ );
17404
+ }
17405
+ logger.info("Credential status: stored, validated.");
17406
+ const label = outcome.metadata.label;
17407
+ if (typeof label === "string" && label.length > 0) {
17408
+ logger.info(`Label: ${label}`);
17409
+ }
17410
+ const expiresAtRaw = outcome.metadata.expiresAt;
17411
+ if (typeof expiresAtRaw === "string") {
17412
+ const expiresAt = new Date(expiresAtRaw);
17413
+ if (!Number.isNaN(expiresAt.getTime())) {
17414
+ logger.info(`Expires: ${expiresAt.toISOString()}`);
17415
+ const daysUntilExpiry = (expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1e3);
17416
+ if (daysUntilExpiry <= ADVANCE_EXPIRY_WARNING_DAYS) {
17417
+ logger.warn(
17418
+ `Credential expires in ${Math.max(0, Math.ceil(daysUntilExpiry))} day(s) (${expiresAt.toISOString()}) \u2014 renew it soon.`
17419
+ );
17420
+ }
17421
+ }
16977
17422
  }
16978
17423
  }
16979
17424
  function runAuthLogoutCommand(_options, logger) {
16980
17425
  if (!fs12.existsSync(CREDENTIALS_PATH)) {
16981
- logger.info("No credentials found. Nothing to remove.");
17426
+ logger.info("No credential found. Nothing to remove.");
16982
17427
  return;
16983
17428
  }
16984
17429
  fs12.unlinkSync(CREDENTIALS_PATH);
16985
- logger.info("License key removed.");
17430
+ logger.info("Credential removed.");
16986
17431
  }
16987
17432
 
16988
17433
  // src/commands/cleanup.ts
@@ -20139,7 +20584,7 @@ import path17 from "path";
20139
20584
  var import_yaml5 = __toESM(require_dist(), 1);
20140
20585
  import fs16 from "fs";
20141
20586
  import path16 from "path";
20142
- function resolveKitHookNames(sourceRoot) {
20587
+ function resolveKitComponentNames(sourceRoot, category) {
20143
20588
  const manifestPath = path16.resolve(sourceRoot, "tangyr-kit.yaml");
20144
20589
  if (!fs16.existsSync(manifestPath)) {
20145
20590
  return void 0;
@@ -20149,10 +20594,10 @@ function resolveKitHookNames(sourceRoot) {
20149
20594
  const doc = (0, import_yaml5.parse)(raw);
20150
20595
  if (doc && typeof doc === "object" && !Array.isArray(doc) && "components" in doc) {
20151
20596
  const components = doc.components;
20152
- if (components && typeof components === "object" && !Array.isArray(components) && "hooks" in components) {
20153
- const hooks = components.hooks;
20154
- if (Array.isArray(hooks) && hooks.every((h) => typeof h === "string")) {
20155
- return hooks;
20597
+ if (components && typeof components === "object" && !Array.isArray(components) && category in components) {
20598
+ const names = components[category];
20599
+ if (Array.isArray(names) && names.every((n7) => typeof n7 === "string")) {
20600
+ return names;
20156
20601
  }
20157
20602
  }
20158
20603
  }
@@ -20160,6 +20605,12 @@ function resolveKitHookNames(sourceRoot) {
20160
20605
  }
20161
20606
  return void 0;
20162
20607
  }
20608
+ function resolveKitHookNames(sourceRoot) {
20609
+ return resolveKitComponentNames(sourceRoot, "hooks");
20610
+ }
20611
+ function resolveKitSkillNames(sourceRoot) {
20612
+ return resolveKitComponentNames(sourceRoot, "skills");
20613
+ }
20163
20614
  function resolveHooksSourceFile(sourceRoot, config) {
20164
20615
  return path16.resolve(sourceRoot, config.source.hooks, "hooks.json");
20165
20616
  }
@@ -21692,14 +22143,14 @@ ${JSON.stringify(config, null, 2)}`);
21692
22143
  checked: enabled
21693
22144
  }))
21694
22145
  });
21695
- const selectedConflict = await dist_default7({
22146
+ const selectedConflict = await dist_default8({
21696
22147
  message: "Conflict policy",
21697
22148
  choices: ["ask", "skip", "backup", "overwrite"].map((value) => ({
21698
22149
  name: value,
21699
22150
  value
21700
22151
  }))
21701
22152
  });
21702
- const selectedVerbosity = await dist_default7({
22153
+ const selectedVerbosity = await dist_default8({
21703
22154
  message: "Verbosity",
21704
22155
  choices: ["quiet", "normal", "verbose"].map((value) => ({
21705
22156
  name: value,
@@ -21771,9 +22222,17 @@ var bundledCopilotManifestPathsByPlatform = {
21771
22222
  };
21772
22223
  function detectTargets(logger) {
21773
22224
  const result = {
21774
- "claude-code": commandExists(process.platform === "win32" ? "where.exe" : "which", ["claude"], logger),
22225
+ "claude-code": commandExists(
22226
+ process.platform === "win32" ? "where.exe" : "which",
22227
+ ["claude"],
22228
+ logger
22229
+ ),
21775
22230
  copilot: detectsCopilot(logger),
21776
- codex: commandExists(process.platform === "win32" ? "where.exe" : "which", ["codex"], logger)
22231
+ codex: commandExists(
22232
+ process.platform === "win32" ? "where.exe" : "which",
22233
+ ["codex"],
22234
+ logger
22235
+ )
21777
22236
  };
21778
22237
  return result;
21779
22238
  }
@@ -21803,9 +22262,16 @@ function detectCopilot(logger, dependencies = {}) {
21803
22262
  ...bundledCopilotEditorCommandsByPlatform[platform] ?? []
21804
22263
  ];
21805
22264
  for (const command of commandCandidates) {
21806
- const result = spawnSyncImpl(command, ["--list-extensions", "--show-versions"]);
22265
+ const result = spawnSyncImpl(command, [
22266
+ "--list-extensions",
22267
+ "--show-versions"
22268
+ ]);
21807
22269
  if (result.error) {
21808
- logDetectionError(`${command} --list-extensions --show-versions`, result.error, logger);
22270
+ logDetectionError(
22271
+ `${command} --list-extensions --show-versions`,
22272
+ result.error,
22273
+ logger
22274
+ );
21809
22275
  continue;
21810
22276
  }
21811
22277
  if (result.status !== 0) {
@@ -21886,10 +22352,14 @@ function commandExists(command, args, logger) {
21886
22352
  }
21887
22353
  function logDetectionError(attemptedCommand, error, logger) {
21888
22354
  if (error.code === "ENOENT") {
21889
- logger?.verbose(`${attemptedCommand}: executable not found while detecting target`);
22355
+ logger?.verbose(
22356
+ `${attemptedCommand}: executable not found while detecting target`
22357
+ );
21890
22358
  return;
21891
22359
  }
21892
- logger?.verbose(`${attemptedCommand} failed: ${error.message ?? "unknown spawn error"}`);
22360
+ logger?.verbose(
22361
+ `${attemptedCommand} failed: ${error.message ?? "unknown spawn error"}`
22362
+ );
21893
22363
  }
21894
22364
 
21895
22365
  // src/commands/doctor.ts
@@ -22204,7 +22674,7 @@ async function runInitCommand(_options, logger) {
22204
22674
  checked: detected
22205
22675
  }))
22206
22676
  });
22207
- const onConflict = await dist_default7({
22677
+ const onConflict = await dist_default8({
22208
22678
  message: "Conflict policy",
22209
22679
  choices: ["ask", "skip", "backup", "overwrite"].map((value) => ({
22210
22680
  name: value,
@@ -26201,7 +26671,7 @@ async function runInstallCommand(options, logger) {
26201
26671
  );
26202
26672
  process.exit(1);
26203
26673
  } else {
26204
- action = await dist_default7({
26674
+ action = await dist_default8({
26205
26675
  message: "Existing installation found. Choose an action:",
26206
26676
  choices: [
26207
26677
  {
@@ -26330,7 +26800,7 @@ Found ${conflictingFindings.length} conflicting target(s). Please choose an acti
26330
26800
  `
26331
26801
  );
26332
26802
  for (const finding of conflictingFindings) {
26333
- const choice = await dist_default7({
26803
+ const choice = await dist_default8({
26334
26804
  message: `Conflict: ${finding.path} (${finding.tool})
26335
26805
  Choose an action:`,
26336
26806
  choices: [
@@ -28419,12 +28889,6 @@ function buildDirectComponents(profile) {
28419
28889
  destination: profile.agents,
28420
28890
  type: "dir"
28421
28891
  },
28422
- {
28423
- component: "skills",
28424
- sourceKey: "skills",
28425
- destination: profile.skills,
28426
- type: "dir"
28427
- },
28428
28892
  {
28429
28893
  component: "commands",
28430
28894
  sourceKey: "commands",
@@ -28490,6 +28954,19 @@ function syncClaudeCode(config, sourceRoot, options, logger, mappings, manifest,
28490
28954
  )
28491
28955
  );
28492
28956
  }
28957
+ if (!shouldSkipComponent6(options, "skills")) {
28958
+ outcomes.push(
28959
+ ...syncClaudeCodeSkills(
28960
+ config,
28961
+ sourceRoot,
28962
+ options,
28963
+ logger,
28964
+ profile.skills,
28965
+ manifest,
28966
+ scopePath
28967
+ )
28968
+ );
28969
+ }
28493
28970
  if (!shouldSkipComponent6(options, "instructions") && manifest && scopePath) {
28494
28971
  const shimPath = profile.instructions_shim;
28495
28972
  const agentsMdPath = path36.resolve(sourceRoot, config.source.instructions);
@@ -28662,6 +29139,128 @@ function syncDirectComponent(config, sourceRoot, options, logger, directComponen
28662
29139
  path: destination
28663
29140
  };
28664
29141
  }
29142
+ function syncClaudeCodeSkills(config, sourceRoot, options, logger, destination, manifest, scopePath) {
29143
+ const source = path36.resolve(sourceRoot, config.source.skills);
29144
+ if (!fs34.existsSync(source)) {
29145
+ return [skippedMissingSource6("skills", source)];
29146
+ }
29147
+ const declaredNames = resolveKitSkillNames(sourceRoot);
29148
+ const isLegacyWholesaleSymlink = isSymlinkPath(destination);
29149
+ if (options.dryRun) {
29150
+ const outcomes2 = [];
29151
+ if (isLegacyWholesaleSymlink) {
29152
+ for (const name of foreignSkillNames(source, declaredNames)) {
29153
+ logger.info(
29154
+ `[dry-run] skills: migrate foreign skill ${path36.join(source, name)} -> ${path36.join(destination, name)}`
29155
+ );
29156
+ }
29157
+ logger.info(
29158
+ `[dry-run] skills: replace wholesale symlink with real directory ${destination}`
29159
+ );
29160
+ }
29161
+ for (const name of declaredNames ?? listSkillDirNames(source)) {
29162
+ const skillDestination = path36.join(destination, name);
29163
+ logger.info(
29164
+ `[dry-run] skills: symlink ${path36.join(source, name)} -> ${skillDestination}`
29165
+ );
29166
+ outcomes2.push({
29167
+ component: "skills",
29168
+ status: "dry-run",
29169
+ path: skillDestination
29170
+ });
29171
+ }
29172
+ return outcomes2;
29173
+ }
29174
+ if (isLegacyWholesaleSymlink) {
29175
+ migrateWholesaleSkillsSymlink(source, destination, declaredNames, logger);
29176
+ }
29177
+ ensureDir(destination);
29178
+ const outcomes = [];
29179
+ for (const name of declaredNames ?? listSkillDirNames(source)) {
29180
+ outcomes.push(
29181
+ syncSingleSkill(
29182
+ config,
29183
+ sourceRoot,
29184
+ options,
29185
+ logger,
29186
+ source,
29187
+ destination,
29188
+ name,
29189
+ manifest,
29190
+ scopePath
29191
+ )
29192
+ );
29193
+ }
29194
+ return outcomes;
29195
+ }
29196
+ function syncSingleSkill(config, sourceRoot, options, logger, source, destination, name, manifest, scopePath) {
29197
+ const skillSource = path36.join(source, name);
29198
+ const skillDestination = path36.join(destination, name);
29199
+ if (!fs34.existsSync(skillSource)) {
29200
+ return skippedMissingSource6("skills", skillSource);
29201
+ }
29202
+ const artifactClass = classifyArtifact(skillDestination, config, sourceRoot);
29203
+ if (artifactClass === "managed-symlink" && isSymlinkTo6(skillDestination, skillSource)) {
29204
+ return {
29205
+ component: "skills",
29206
+ status: "skipped-current",
29207
+ path: skillDestination
29208
+ };
29209
+ }
29210
+ if (artifactClass === "managed-stale" && isOurStaleSymlink(skillDestination, /* @__PURE__ */ new Set([skillDestination]), config)) {
29211
+ fs34.unlinkSync(skillDestination);
29212
+ } else if (artifactClass === "unmanaged-conflict" && !handleConflict6(
29213
+ skillDestination,
29214
+ config,
29215
+ options,
29216
+ logger,
29217
+ manifest,
29218
+ scopePath
29219
+ )) {
29220
+ return { component: "skills", status: "conflict", path: skillDestination };
29221
+ } else if (artifactClass === "managed-symlink") {
29222
+ fs34.unlinkSync(skillDestination);
29223
+ }
29224
+ createRelativeSymlink(skillSource, skillDestination, "dir");
29225
+ return { component: "skills", status: "success", path: skillDestination };
29226
+ }
29227
+ function migrateWholesaleSkillsSymlink(source, destination, declaredNames, logger) {
29228
+ const foreignNames = foreignSkillNames(source, declaredNames);
29229
+ fs34.unlinkSync(destination);
29230
+ ensureDir(destination);
29231
+ for (const name of foreignNames) {
29232
+ const from = path36.join(source, name);
29233
+ const to = path36.join(destination, name);
29234
+ if (fs34.existsSync(to)) {
29235
+ logger.warn(
29236
+ `skills migration: ${to} already exists \u2014 skipping move of ${from} to avoid overwrite`
29237
+ );
29238
+ continue;
29239
+ }
29240
+ fs34.renameSync(from, to);
29241
+ logger.info(`skills migration: moved ${from} -> ${to}`);
29242
+ }
29243
+ }
29244
+ function foreignSkillNames(source, declaredNames) {
29245
+ if (!declaredNames) {
29246
+ return [];
29247
+ }
29248
+ const declaredSet = new Set(declaredNames);
29249
+ return listSkillDirNames(source).filter((name) => !declaredSet.has(name));
29250
+ }
29251
+ function listSkillDirNames(dir) {
29252
+ if (!fs34.existsSync(dir)) {
29253
+ return [];
29254
+ }
29255
+ return fs34.readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name);
29256
+ }
29257
+ function isSymlinkPath(filePath) {
29258
+ try {
29259
+ return fs34.lstatSync(filePath).isSymbolicLink();
29260
+ } catch {
29261
+ return false;
29262
+ }
29263
+ }
28665
29264
  function syncHookScriptsBridge(config, sourceRoot, options, logger, destination, manifest, scopePath) {
28666
29265
  const source = path36.resolve(sourceRoot, config.source.hooks);
28667
29266
  if (!fs34.existsSync(source)) {
@@ -29946,6 +30545,7 @@ var commandNames = [
29946
30545
  "probe"
29947
30546
  ];
29948
30547
  async function main() {
30548
+ assertSupportedNodeRuntime();
29949
30549
  process.on("SIGINT", () => {
29950
30550
  process.exit(EXIT_USER_INTERRUPT);
29951
30551
  });
@@ -29976,6 +30576,9 @@ function buildProgram() {
29976
30576
  ).option(
29977
30577
  "-y, --yes",
29978
30578
  "Skip interactive confirmation and apply non-interactive conflict handling"
30579
+ ).option(
30580
+ "--non-interactive",
30581
+ "Never prompt; fail closed when a required input is missing (auth login)"
29979
30582
  ).option("-v, --verbose", "Emit detailed output").option("-q, --quiet", "Emit errors only").option("--no-color", "Disable colorized output").option(
29980
30583
  "--claude-config-dir <path>",
29981
30584
  "Claude Code global root directory (repeatable; overrides claudeCodeGlobalPaths in config)",
@@ -30002,19 +30605,23 @@ function buildProgram() {
30002
30605
  );
30003
30606
  }
30004
30607
  );
30005
- const authCmd = program2.command("auth").description("License key management");
30006
- authCmd.command("login").description("Authenticate with a license key").option("--key <key>", "License key").action(async (options) => {
30608
+ const authCmd = program2.command("auth").description("Credential management");
30609
+ authCmd.command("login").description("Authenticate with a client ID/secret credential pair").option(
30610
+ "--skip-validation",
30611
+ "Store the credential without contacting the configured origin"
30612
+ ).action(async (options) => {
30007
30613
  const context = commandContext(program2);
30614
+ const nonInteractive = context.options.nonInteractive === true || !process.stdin.isTTY;
30008
30615
  await runAuthLoginCommand(
30009
- { ...context.options, ...options },
30616
+ { ...context.options, ...options, nonInteractive },
30010
30617
  context.logger
30011
30618
  );
30012
30619
  });
30013
- authCmd.command("status").description("Show current authentication status").action(() => {
30620
+ authCmd.command("status").description("Show current authentication status").action(async () => {
30014
30621
  const context = commandContext(program2);
30015
- runAuthStatusCommand({ ...context.options }, context.logger);
30622
+ await runAuthStatusCommand({ ...context.options }, context.logger);
30016
30623
  });
30017
- authCmd.command("logout").description("Remove stored license key").action(() => {
30624
+ authCmd.command("logout").description("Remove stored credential").action(() => {
30018
30625
  const context = commandContext(program2);
30019
30626
  runAuthLogoutCommand({ ...context.options }, context.logger);
30020
30627
  });
@@ -30103,7 +30710,7 @@ function buildProgram() {
30103
30710
  return program2;
30104
30711
  }
30105
30712
  async function runInteractiveMenu(program2, logger) {
30106
- const choice = await dist_default7({
30713
+ const choice = await dist_default8({
30107
30714
  message: "Select a Tangyr command",
30108
30715
  choices: [
30109
30716
  { name: "Assess", value: "assess" },
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@alessandroraffa/tangyr",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "CLI for the Tangyr discipline — install and manage operating kits for AI coding tools",
5
5
  "license": "MIT",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
6
9
  "type": "module",
7
10
  "bin": {
8
11
  "tangyr": "./dist/index.js"
@@ -33,9 +36,13 @@
33
36
  "bugs": {
34
37
  "url": "https://github.com/alessandroraffa/tangyr-cli/issues"
35
38
  },
39
+ "overrides": {
40
+ "esbuild": "^0.28.1"
41
+ },
36
42
  "devDependencies": {
37
43
  "@iarna/toml": "^2.2.5",
38
44
  "@inquirer/prompts": "^8.4.1",
45
+ "@types/node": "^22.20.1",
39
46
  "chalk": "^5.6.2",
40
47
  "commander": "^14.0.3",
41
48
  "execa": "^9.0.0",
@@ -46,6 +53,5 @@
46
53
  "typescript": "^5.0.0",
47
54
  "vitest": "^3.0.0",
48
55
  "yaml": "^2.8.3"
49
- },
50
- "dependencies": {}
56
+ }
51
57
  }