magi-linear-axi 0.2.3

Agent-native Linear GraphQL CLI with TOON output
Documentation
pub mod cycle;
pub mod document;
pub mod initiative;
pub mod initiative_update;
pub mod issue;
pub mod label;
pub mod milestone;
pub mod project;
pub mod project_update;
pub mod resource;
pub mod team;
pub mod user;

use crate::{
    cli::Cli,
    cli::{ApiArgs, AuthCommand, IssueCommand, SetupArgs},
    config,
    error::AppError,
    output::Output,
};
use serde_json::{Value, json};
use std::{fs, path::PathBuf};
fn executable_path() -> Result<String, AppError> {
    std::env::current_exe()
        .map(|path| path.to_string_lossy().into_owned())
        .map_err(|e| AppError::config(format!("cannot resolve current executable: {e}")))
}

fn js_string_literal(value: &str) -> String {
    serde_json::to_string(value).expect("string serialization cannot fail")
}
pub fn parse_variable(s: &str) -> Result<(String, Value), String> {
    let (k, v) = s.split_once('=').ok_or("expected key=value")?;
    Ok((
        k.into(),
        serde_json::from_str(v).unwrap_or(Value::String(v.into())),
    ))
}
fn ctx(cli: &Cli) -> Result<(crate::client::LinearClient, Output), AppError> {
    let c = config::Config::load(cli.endpoint.clone(), cli.workspace.clone())?;
    let token = config::api_key()?;
    Ok((
        crate::client::LinearClient::new(c.endpoint, token)?,
        Output::new(cli.format.clone(), cli.full),
    ))
}

pub fn api(cli: &Cli, a: ApiArgs) -> Result<(), AppError> {
    let q = Cli::read_query(a.query)?;
    let mut vars = if let Some(v) = a.variables_json {
        serde_json::from_str::<serde_json::Map<String, Value>>(&v)
            .map_err(|e| AppError::usage(format!("invalid --variables-json: {e}")))?
    } else {
        serde_json::Map::new()
    };
    for (k, v) in a.variable {
        vars.insert(k, v);
    }
    let (c, o) = ctx(cli)?;
    let v = if a.paginate {
        c.raw_paginate(&q, Value::Object(vars))?
    } else {
        c.raw(&q, Value::Object(vars))?
    };
    if !a.silent { o.render_owned(v) } else { Ok(()) }
}
pub fn schema(cli: &Cli, path: Option<PathBuf>) -> Result<(), AppError> {
    let (c, o) = ctx(cli)?;
    let v = c.query("query FullSchema { __schema { queryType { name } mutationType { name } subscriptionType { name } types { kind name description fields(includeDeprecated:true) { name description isDeprecated deprecationReason args { name description defaultValue type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } inputFields { name description defaultValue type { kind name ofType { kind name ofType { kind name ofType { kind name } } } } } interfaces { kind name } enumValues(includeDeprecated:true) { name description isDeprecated deprecationReason } possibleTypes { kind name } } } }", json!({}))?;
    if let Some(p) = path {
        config::write_atomic(&p, &serde_json::to_string_pretty(&v).unwrap())
    } else {
        o.render_owned(v)
    }
}
pub fn auth(cli: &Cli, cmd: AuthCommand) -> Result<(), AppError> {
    match cmd {
        AuthCommand::Whoami => {
            let (client, output) = ctx(cli)?;
            output
                .render_owned(client.query("query Viewer { viewer { id name email } }", json!({}))?)
        }
    }
}
pub fn home(cli: &Cli) -> Result<(), AppError> {
    let bin = std::env::current_exe()
        .ok()
        .map(|path| display_home_path(&path))
        .unwrap_or_else(|| "magi-linear-axi".into());
    Output::new(cli.format.clone(), cli.full).render_owned(json!({
        "bin": bin,
        "description": "Agent-native Linear GraphQL CLI",
        "live": {"format": cli.format, "auth": "LINEAR_API_KEY"},
        "help": ["magi-linear-axi --help", "magi-linear-axi api <graphql>", "magi-linear-axi issue list"]
    }))
}
fn display_home_path(path: &std::path::Path) -> String {
    let path = path.to_string_lossy();
    std::env::var_os("HOME")
        .and_then(|home| {
            path.strip_prefix(home.to_string_lossy().as_ref())
                .map(str::to_owned)
        })
        .filter(|suffix| !suffix.is_empty())
        .map(|suffix| format!("~{suffix}"))
        .unwrap_or_else(|| path.into_owned())
}
pub fn setup(a: SetupArgs) -> Result<(), AppError> {
    let executable = executable_path()?;
    let executable_literal = js_string_literal(&executable);
    let explicit_home = a.path.clone();
    let home = a.path.unwrap_or_else(|| {
        std::env::var_os("HOME")
            .map(PathBuf::from)
            .unwrap_or_else(|| PathBuf::from("."))
    });
    let targets: Vec<&str> = if !a.claude && !a.codex && !a.opencode && !a.pi {
        vec!["claude", "codex", "opencode", "pi"]
    } else {
        [
            ("claude", a.claude),
            ("codex", a.codex),
            ("opencode", a.opencode),
            ("pi", a.pi),
        ]
        .into_iter()
        .filter_map(|(n, yes)| yes.then_some(n))
        .collect()
    };
    for target in targets {
        match target {
            "claude" => {
                let p = home.join(".claude/settings.json");
                let mut v = read_json(&p)?;
                merge_claude_hooks(&p, &mut v, &executable)?;
                write_json(&p, &v)?;
            }
            "codex" => {
                let cfg = home.join(".codex/config.toml");
                let mut parsed = read_toml(&cfg)?;
                let table = parsed
                    .as_table_mut()
                    .ok_or_else(|| schema_error(&cfg, "top-level table"))?;
                let features = table
                    .entry("features")
                    .or_insert_with(|| toml::Value::Table(Default::default()))
                    .as_table_mut()
                    .ok_or_else(|| schema_error(&cfg, "features table"))?;
                features.insert("hooks".into(), toml::Value::Boolean(true));
                let p = home.join(".codex/hooks.json");
                let mut v = read_json(&p)?;
                merge_codex_hooks(&p, &mut v, &executable)?;
                write_json(&p, &v)?;
                config::write_atomic(
                    &cfg,
                    &toml::to_string_pretty(&parsed)
                        .map_err(|e| AppError::config(format!("{}: {e}", cfg.display())))?,
                )?;
            }
            "opencode" => {
                let p = home.join(".config/opencode/plugins/magi-linear-axi.js");
                let plugin = format!(
                    "export const LinearAxiPlugin = async ({{ $, directory }}) => {{\n  return {{\n    \"experimental.chat.system.transform\": async (_input, output) => {{\n      const result = await $`${{{executable_literal}}} --format toon`.cwd(directory).quiet().nothrow();\n      if (result.exitCode === 0) {{\n        const context = result.stdout.toString().trim().slice(0, 8192);\n        if (context) output.system.push(context);\n      }}\n    }},\n  }};\n}};\n",
                );
                config::write_atomic(&p, &plugin)?;
            }
            "pi" => {
                let agent_root = explicit_home
                    .clone()
                    .map(|path| path.join(".pi/agent"))
                    .or_else(|| {
                        std::env::var_os("PI_CODING_AGENT_DIR")
                            .filter(|p| !p.is_empty())
                            .map(PathBuf::from)
                    })
                    .or_else(|| {
                        std::env::var_os("HOME").map(|path| PathBuf::from(path).join(".pi/agent"))
                    })
                    .unwrap_or_else(|| PathBuf::from(".pi/agent"));
                config::write_atomic(
                    &agent_root.join("extensions/magi-linear-axi.ts"),
                    &pi_extension(&executable_literal),
                )?;
            }
            _ => unreachable!(),
        }
    }
    Ok(())
}
fn pi_extension(executable_literal: &str) -> String {
    r#"import { spawn } from "node:child_process";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const executable = __EXECUTABLE__;
let cachedContext = "";

function loadContext(cwd: string): Promise<string> {
  return new Promise((resolve) => {
    let output = "";
    let settled = false;
    let timeout: ReturnType<typeof setTimeout>;
    const finish = (context: string) => {
      if (settled) return;
      settled = true;
      clearTimeout(timeout);
      resolve(context);
    };
    let child;
    try {
      child = spawn(executable, ["--format", "toon"], {
        cwd,
        stdio: ["ignore", "pipe", "ignore"],
      });
    } catch {
      finish("");
      return;
    }
    timeout = setTimeout(() => {
      child.kill();
      finish("");
    }, 3000);
    child.stdout?.setEncoding("utf8");
    child.stdout?.on("data", (chunk: string) => {
      output += chunk.slice(0, Math.max(0, 8192 - output.length));
    });
    child.on("error", () => finish(""));
    child.on("close", (code) => {
      finish(code === 0 ? output.trim().slice(0, 8192) : "");
    });
  });
}

export default function (pi: ExtensionAPI) {
  pi.on("session_start", async (_event, ctx) => {
    cachedContext = await loadContext(ctx.cwd);
  });
  pi.on("before_agent_start", async (event) => {
    return cachedContext
      ? { systemPrompt: `${event.systemPrompt}\n\n${cachedContext}` }
      : undefined;
  });
}
"#
    .replace("__EXECUTABLE__", executable_literal)
}
fn schema_error(path: &std::path::Path, expected: &str) -> AppError {
    AppError::config(format!("{}: expected {expected}", path.display()))
}
fn read_toml(path: &std::path::Path) -> Result<toml::Value, AppError> {
    let raw = match fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(e) => {
            return Err(AppError::config(format!(
                "cannot read config {}: {e}",
                path.display()
            )));
        }
    };
    if raw.trim().is_empty() {
        Ok(toml::Value::Table(Default::default()))
    } else {
        toml::from_str(&raw)
            .map_err(|e| AppError::config(format!("cannot parse config {}: {e}", path.display())))
    }
}
fn merge_claude_hooks(
    path: &std::path::Path,
    value: &mut Value,
    executable: &str,
) -> Result<(), AppError> {
    let root = value
        .as_object_mut()
        .ok_or_else(|| schema_error(path, "object"))?;
    let hooks = root.entry("hooks").or_insert_with(|| json!({}));
    let events = hooks
        .as_object_mut()
        .ok_or_else(|| schema_error(path, "hooks object"))?;
    let session = events.entry("SessionStart").or_insert_with(|| json!([]));
    let entries = session
        .as_array_mut()
        .ok_or_else(|| schema_error(path, "hooks.SessionStart array"))?;
    let mut found = false;
    entries.retain_mut(|entry| {
        let Some(object) = entry.as_object_mut() else {
            return true;
        };
        let Some(nested) = object.get_mut("hooks") else {
            return true;
        };
        let Some(nested) = nested.as_array_mut() else {
            return true;
        };
        nested.retain_mut(|hook| {
            let Some(hook) = hook.as_object_mut() else {
                return true;
            };
            let Some(command) = hook
                .get_mut("command")
                .and_then(|v| v.as_str())
                .map(str::to_owned)
            else {
                return true;
            };
            if is_managed_command(&command) {
                if found {
                    false
                } else {
                    hook.insert("command".into(), json!(executable));
                    found = true;
                    true
                }
            } else {
                true
            }
        });
        true
    });
    if !found {
        entries.push(json!({"hooks":[{"type":"command","command":executable}]}));
    }
    Ok(())
}
fn merge_codex_hooks(
    path: &std::path::Path,
    value: &mut Value,
    executable: &str,
) -> Result<(), AppError> {
    let root = value
        .as_object_mut()
        .ok_or_else(|| schema_error(path, "object"))?;
    let hooks = root.entry("hooks").or_insert_with(|| json!({}));
    let events = hooks
        .as_object_mut()
        .ok_or_else(|| schema_error(path, "hooks object"))?;
    let session = events.entry("SessionStart").or_insert_with(|| json!([]));
    let entries = session
        .as_array_mut()
        .ok_or_else(|| schema_error(path, "hooks.SessionStart array"))?;
    let mut found = false;
    entries.retain_mut(|entry| {
        let Some(object) = entry.as_object_mut() else {
            return true;
        };
        let Some(command) = object
            .get_mut("command")
            .and_then(|v| v.as_str())
            .map(str::to_owned)
        else {
            return true;
        };
        if is_managed_command(&command) {
            if found {
                false
            } else {
                object.insert("command".into(), json!(executable));
                found = true;
                true
            }
        } else {
            true
        }
    });
    if !found {
        entries.push(json!({"command":executable}));
    }
    Ok(())
}
fn is_managed_command(command: &str) -> bool {
    command == "magi-linear-axi"
        || std::path::Path::new(command)
            .file_name()
            .is_some_and(|name| name == "magi-linear-axi")
}
fn read_json(path: &std::path::Path) -> Result<Value, AppError> {
    if !path.exists() {
        return Ok(json!({}));
    }
    let raw = fs::read_to_string(path)
        .map_err(|e| AppError::config(format!("cannot read config {}: {e}", path.display())))?;
    serde_json::from_str(&raw)
        .map_err(|e| AppError::config(format!("cannot parse config {}: {e}", path.display())))
}
fn write_json(path: &std::path::Path, value: &Value) -> Result<(), AppError> {
    config::write_atomic(
        path,
        &serde_json::to_string_pretty(value)
            .map_err(|e| AppError::config(format!("{}: {e}", path.display())))?,
    )
}

pub fn issues(cli: &Cli, cmd: Option<IssueCommand>) -> Result<(), AppError> {
    let command = cmd.unwrap_or(IssueCommand::Query(crate::cli::IssueQueryArgs::default()));
    issue::execute(cli, command)
}